init
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.vs/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
135
backend/Program.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll",
|
||||||
|
policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
app.MapGet("/ping", () => "pong");
|
||||||
|
|
||||||
|
|
||||||
|
// Define the API routes
|
||||||
|
app.MapGet("/api/v1/songs/{hash}", (string hash) =>
|
||||||
|
{
|
||||||
|
|
||||||
|
return Results.Ok(new { hash });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/songs/recent", (int? limit, int? offset) =>
|
||||||
|
{
|
||||||
|
var limitValue = limit ?? 100; // default to 10 if not provided
|
||||||
|
var offsetValue = offset ?? 0; // default to 0 if not provided
|
||||||
|
|
||||||
|
return Results.Json(Osudb.Instance.GetRecent(limitValue, offsetValue));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/songs/favorite", (int? limit, int? offset) =>
|
||||||
|
{
|
||||||
|
var limitValue = limit ?? 10; // default to 10 if not provided
|
||||||
|
var offsetValue = offset ?? 0; // default to 0 if not provided
|
||||||
|
|
||||||
|
return Results.Ok(new { Limit = limitValue, Offset = offsetValue, Message = "List of favorite songs" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/songs/{hash}", (string hash) =>
|
||||||
|
{
|
||||||
|
return Results.Ok($"Details for song with hash {hash}");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/collections/", async (int? limit, int? offset, [FromServices] IMemoryCache cache) =>
|
||||||
|
{
|
||||||
|
const string cacheKey = "collections";
|
||||||
|
|
||||||
|
if (!cache.TryGetValue(cacheKey, out var collections))
|
||||||
|
{
|
||||||
|
|
||||||
|
collections = Osudb.Instance.GetCollections();
|
||||||
|
|
||||||
|
var cacheEntryOptions = new MemoryCacheEntryOptions()
|
||||||
|
.SetSlidingExpiration(TimeSpan.FromDays(1))
|
||||||
|
.SetAbsoluteExpiration(TimeSpan.FromDays(3));
|
||||||
|
|
||||||
|
cache.Set(cacheKey, collections, cacheEntryOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Json(collections);
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/collection/{index}", (int index) =>
|
||||||
|
{
|
||||||
|
return Results.Json(Osudb.Instance.GetCollection(index));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/audio/{*fileName}", async (string fileName, HttpContext context) =>
|
||||||
|
{
|
||||||
|
var decodedFileName = Uri.UnescapeDataString(fileName);
|
||||||
|
var filePath = Path.Combine(Osudb.osufolder, "Songs", decodedFileName);
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Not Found: {filePath}");
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileExtension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
var contentType = fileExtension switch
|
||||||
|
{
|
||||||
|
".mp3" => "audio/mpeg",
|
||||||
|
".wav" => "audio/wav",
|
||||||
|
".ogg" => "audio/ogg",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
|
||||||
|
return Results.Stream(fileStream, contentType, enableRangeProcessing: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.MapGet("/api/v1/images/{*filename}", async (string filename) =>
|
||||||
|
{
|
||||||
|
var decodedFileName = Uri.UnescapeDataString(filename);
|
||||||
|
var filePath = Path.Combine(Osudb.osufolder, "Songs", decodedFileName);
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Not Found: {filePath}");
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileExtension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
var contentType = fileExtension switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".bmp" => "image/bmp",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
|
||||||
|
|
||||||
|
return Results.Stream(fileStream, contentType, filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app.Run();
|
||||||
28
backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:45205",
|
||||||
|
"sslPort": 44305
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"shitweb": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "https://localhost:7254;http://localhost:5153",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
216
backend/osudb.cs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using OsuParsers.Beatmaps;
|
||||||
|
using OsuParsers.Database;
|
||||||
|
using OsuParsers.Database.Objects;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
public class Osudb
|
||||||
|
{
|
||||||
|
private static Osudb instance = null;
|
||||||
|
private static readonly object padlock = new object();
|
||||||
|
public static string osufolder { get; private set; }
|
||||||
|
public static OsuDatabase osuDatabase { get; private set; }
|
||||||
|
public static CollectionDatabase CollectionDb { get; private set; }
|
||||||
|
|
||||||
|
static Osudb()
|
||||||
|
{
|
||||||
|
var key = Registry.GetValue(
|
||||||
|
@"HKEY_LOCAL_MACHINE\SOFTWARE\Classes\osu\shell\open\command",
|
||||||
|
"",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (key != null)
|
||||||
|
{
|
||||||
|
string[] keyparts = key.ToString().Split('"');
|
||||||
|
|
||||||
|
osufolder = Path.GetDirectoryName(keyparts[1]);
|
||||||
|
|
||||||
|
Parse(osufolder);
|
||||||
|
}
|
||||||
|
else throw new Exception("Osu not Installed... ");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Parse(string filepath)
|
||||||
|
{
|
||||||
|
string file = "/osu!.db";
|
||||||
|
if (File.Exists(filepath + file))
|
||||||
|
{
|
||||||
|
using (FileStream fileStream = new FileStream(filepath + file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize: 4096, useAsync: true))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Parsing {file}");
|
||||||
|
osuDatabase = OsuParsers.Decoders.DatabaseDecoder.DecodeOsu($"{filepath}{file}");
|
||||||
|
Console.WriteLine($"Parsed {file}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file = "/collection.db";
|
||||||
|
if (File.Exists(filepath + file))
|
||||||
|
{
|
||||||
|
using (FileStream fileStream = new FileStream(filepath + file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, bufferSize: 4096, useAsync: true))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Parsing {file}");
|
||||||
|
CollectionDb = OsuParsers.Decoders.DatabaseDecoder.DecodeCollection($"{filepath}{file}");
|
||||||
|
Console.WriteLine($"Parsed {file}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Osudb Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (padlock)
|
||||||
|
{
|
||||||
|
if (instance == null)
|
||||||
|
{
|
||||||
|
instance = new Osudb();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbBeatmap GetBeatmapbyHash(string Hash)
|
||||||
|
{
|
||||||
|
if (Hash == null || osuDatabase == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return osuDatabase.Beatmaps.FirstOrDefault(beatmap => beatmap.MD5Hash == Hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OsuParsers.Database.Objects.Collection GetCollectionbyName(string name) {
|
||||||
|
|
||||||
|
return CollectionDb.Collections.FirstOrDefault(collection => collection.Name == name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OsuParsers.Database.Objects.Collection GetCollectionbyIndex(int index)
|
||||||
|
{
|
||||||
|
|
||||||
|
return CollectionDb.Collections.ElementAtOrDefault(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Collection GetCollection(int index) {
|
||||||
|
|
||||||
|
var collection = GetCollectionbyIndex(index);
|
||||||
|
if (collection == null) { return null; }
|
||||||
|
|
||||||
|
List<Song> songs = new List<Song>();
|
||||||
|
var activeId = 0;
|
||||||
|
|
||||||
|
collection.MD5Hashes.ForEach(hash =>
|
||||||
|
{
|
||||||
|
var beatmap = GetBeatmapbyHash(hash);
|
||||||
|
if (beatmap == null) { return; }
|
||||||
|
|
||||||
|
if (activeId == beatmap.BeatmapSetId) { return; }
|
||||||
|
activeId = beatmap.BeatmapSetId;
|
||||||
|
//todo
|
||||||
|
string img = getBG(beatmap.FolderName, beatmap.FileName);
|
||||||
|
|
||||||
|
songs.Add(new Song(hash: beatmap.MD5Hash, name: beatmap.Title, artist: beatmap.Artist, length: beatmap.TotalTime, url: $"{beatmap.FolderName}/{beatmap.AudioFileName}" , previewimage: img, mapper: beatmap.Creator));
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Collection(collection.Name, songs.Count, songs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<CollectionPreview> GetCollections()
|
||||||
|
{
|
||||||
|
|
||||||
|
List<CollectionPreview> collections = new List<CollectionPreview>();
|
||||||
|
|
||||||
|
for (int i = 0; i < CollectionDb.Collections.Count; i++) {
|
||||||
|
var collection = CollectionDb.Collections[i];
|
||||||
|
|
||||||
|
var beatmap = GetBeatmapbyHash(collection.MD5Hashes.FirstOrDefault());
|
||||||
|
|
||||||
|
//todo
|
||||||
|
string img = getBG(beatmap.FolderName, beatmap.FileName);
|
||||||
|
|
||||||
|
collections.Add(new CollectionPreview(index: i, name: collection.Name, previewimage: img, length: collection.Count));
|
||||||
|
};
|
||||||
|
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Song> GetRecent(int limit, int offset)
|
||||||
|
{
|
||||||
|
var recent = new List<Song>();
|
||||||
|
if(limit > 100 && limit < 0) {
|
||||||
|
limit = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
var size = osuDatabase.Beatmaps.Count -1;
|
||||||
|
for (int i = size - offset; i > size - offset - limit; i--)
|
||||||
|
{
|
||||||
|
var beatmap = osuDatabase.Beatmaps.ElementAt(i);
|
||||||
|
if (beatmap == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string img = getBG(beatmap.FolderName, beatmap.FileName);
|
||||||
|
|
||||||
|
recent.Add(new Song(
|
||||||
|
name: beatmap.FileName,
|
||||||
|
hash: beatmap.MD5Hash,
|
||||||
|
artist: beatmap.Artist,
|
||||||
|
length: beatmap.TotalTime,
|
||||||
|
url: $"{beatmap.FolderName}/{beatmap.AudioFileName}",
|
||||||
|
previewimage: img,
|
||||||
|
mapper: beatmap.Creator));
|
||||||
|
}
|
||||||
|
|
||||||
|
return recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Song> GetFavorites()
|
||||||
|
{
|
||||||
|
var recent = new List<Song>();
|
||||||
|
/*
|
||||||
|
osuDatabase.Beatmaps.ForEach(beatmap =>
|
||||||
|
{
|
||||||
|
Console.WriteLine(beatmap.LastModifiedTime);
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string getBG(string songfolder, string diff)
|
||||||
|
{
|
||||||
|
string folderpath = Path.Combine(songfolder, diff);
|
||||||
|
string filepath = Path.Combine(osufolder, "Songs", folderpath);
|
||||||
|
|
||||||
|
if (File.Exists(filepath))
|
||||||
|
{
|
||||||
|
string fileContents = File.ReadAllText($@"{filepath}"); // Read the contents of the file
|
||||||
|
|
||||||
|
string pattern = @"\d+,\d+,""(?<image_filename>[^""]+\.[a-zA-Z]+)"",\d+,\d+";
|
||||||
|
|
||||||
|
Match match = Regex.Match(fileContents, pattern);
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
string background = match.Groups["image_filename"].Value;
|
||||||
|
|
||||||
|
return Path.Combine(songfolder, background);
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = @"\d+,\d+,""(?<image_filename>[^""]+\.[a-zA-Z]+)""";
|
||||||
|
|
||||||
|
match = Regex.Match(fileContents, pattern);
|
||||||
|
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
string background = match.Groups["image_filename"].Value;
|
||||||
|
return Path.Combine(songfolder, background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/shitweb.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="OsuParsers" Version="1.7.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
25
backend/shitweb.sln
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.8.34322.80
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "shitweb", "shitweb.csproj", "{A81ACB49-5C0C-42D5-88DA-3BC7E256F859}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{A81ACB49-5C0C-42D5-88DA-3BC7E256F859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{A81ACB49-5C0C-42D5-88DA-3BC7E256F859}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{A81ACB49-5C0C-42D5-88DA-3BC7E256F859}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{A81ACB49-5C0C-42D5-88DA-3BC7E256F859}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {A5EA63AB-B1DD-4171-922C-D01C758AD544}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
40
backend/types.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
public class Song{
|
||||||
|
public string hash {get; set;}
|
||||||
|
public string name {get; set;}
|
||||||
|
public string artist {get; set;}
|
||||||
|
public int length {get; set;}
|
||||||
|
public string url { get; set; }
|
||||||
|
public string previewimage {get; set;}
|
||||||
|
public string mapper {get; set;}
|
||||||
|
|
||||||
|
public Song(string hash, string name, string artist, int length, string url, string previewimage, string mapper) {
|
||||||
|
this.hash = hash; this.name = name; this.artist = artist; this.length = length; this.url = url; this.previewimage = previewimage; this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CollectionPreview{
|
||||||
|
public int index { get; set;}
|
||||||
|
public string name {get; set;}
|
||||||
|
public int length {get; set;}
|
||||||
|
public string previewimage {get; set;}
|
||||||
|
|
||||||
|
private CollectionPreview() { }
|
||||||
|
|
||||||
|
public CollectionPreview(int index, string name, string previewimage, int length) {
|
||||||
|
this.index = index; this.name = name; this.previewimage = previewimage; this.length = length;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
public class Collection{
|
||||||
|
public string name {get; set;}
|
||||||
|
public int length {get; set;}
|
||||||
|
public List<Song> songs { get; set;}
|
||||||
|
|
||||||
|
private Collection() { }
|
||||||
|
|
||||||
|
public Collection(string name, int length, List<Song> songs) {
|
||||||
|
this.name = name; this.length = length; this.songs = songs;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
30
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
33
frontend/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# osu-music
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
21
frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||||
|
integrity="sha512-Kc323vGBEqzTmouAECnVceyQqyqdsSiqLQISBL29aUW4U/M7pSPA/gEUZQqv1cwx4OnYxTxve5UMg5GT6L4JJg=="
|
||||||
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
<title>osu! music player, not affiliated with the osu! trademark</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "osu-music",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"dev": "vite",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "vue-tsc --build --force"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"register-service-worker": "^1.7.2",
|
||||||
|
"vue": "^3.4.29",
|
||||||
|
"vue-router": "^4.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node20": "^20.1.4",
|
||||||
|
"@types/node": "^20.14.5",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.5",
|
||||||
|
"@vue/cli-plugin-pwa": "~5.0.0",
|
||||||
|
"@vue/tsconfig": "^0.5.1",
|
||||||
|
"npm-run-all2": "^6.2.0",
|
||||||
|
"typescript": "~5.4.0",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"vue-tsc": "^2.0.21"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/img/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
frontend/public/img/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/public/img/icons/android-chrome-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/img/icons/android-chrome-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/img/icons/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend/public/img/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/public/img/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 799 B |
BIN
frontend/public/img/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/public/img/icons/msapplication-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/img/icons/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
3
frontend/public/img/icons/safari-pinned-tab.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 215 B |
20
frontend/public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "My Vue 3 App",
|
||||||
|
"short_name": "My App",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "img/icons/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "img/icons/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": "/index.html",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#4DBA87"
|
||||||
|
}
|
||||||
2
frontend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
50
frontend/src/App.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
|
import NowPlaying from './components/NowPlaying.vue'
|
||||||
|
import Footer from './components/Footer.vue'
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useHeaderStore } from '@/stores/headerStore';
|
||||||
|
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
|
||||||
|
const showFooter = ref(true);
|
||||||
|
const showNowPlaying = ref(true);
|
||||||
|
const route = useRoute();
|
||||||
|
function hide() {
|
||||||
|
showHeader.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(route, async (to) => {
|
||||||
|
|
||||||
|
if (route.path.startsWith("/nowplaying")) {
|
||||||
|
showNowPlaying.value = false;
|
||||||
|
} else {
|
||||||
|
showNowPlaying.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.path.startsWith("/menu")) {
|
||||||
|
headerStore.hide();
|
||||||
|
} else {
|
||||||
|
headerStore.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<contentw class="flex flex-col h-screen max-h-screen wrapper text-pink-500 text-xl">
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
|
||||||
|
<Transition>
|
||||||
|
<NowPlaying v-show="showNowPlaying" />
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
</contentw>
|
||||||
|
</template>
|
||||||
19
frontend/src/assets/animations.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.v-enter-from, .v-leave-to
|
||||||
|
{
|
||||||
|
animation: fadeIn 0.5s;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-enter-to, .v-leave-from {
|
||||||
|
animation: fadeOut 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
32
frontend/src/assets/base.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
body {
|
||||||
|
background-color: rgba(28, 23, 25, 0);
|
||||||
|
margin:0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* .{
|
||||||
|
min-height: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop--light {
|
||||||
|
background-color: rgba(70, 57, 63, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop--medium {
|
||||||
|
background-color: rgba(56, 46, 50, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop--medium--light {
|
||||||
|
background-color: rgba(42, 34, 38, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop--medium--dark {
|
||||||
|
background-color: rgba(56, 46, 50, 1)
|
||||||
|
}
|
||||||
|
.backdrop--dark {
|
||||||
|
background-color: rgba(28, 23, 25, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
.router-link-active {
|
||||||
|
color: #ec4899;
|
||||||
|
}
|
||||||
1
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
36
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-weight: normal;
|
||||||
|
background-color: rgba(28, 23, 25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
color: rgba(221, 159, 8, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
padding: 0 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/components/CollectionItem.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SongItem from '../components/SongItem.vue'
|
||||||
|
import type { Song, CollectionPreview } from '../script/types'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import CollectionListItem from '../components/CollectionListItem.vue'
|
||||||
|
import { useAudioStore } from '@/stores/audioStore';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const audioStore = useAudioStore();
|
||||||
|
|
||||||
|
const songs = ref<Song[]>([]);
|
||||||
|
const name = ref('name');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await userStore.fetchCollection(Number(route.params.id));
|
||||||
|
console.log(data)
|
||||||
|
|
||||||
|
data.songs.forEach(song => {
|
||||||
|
song.previewimage = `${userStore.baseUrl}api/v1/images/${song.previewimage}`;
|
||||||
|
song.url = `${userStore.baseUrl}api/v1/audio/${song.url}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
name.value = data.name;
|
||||||
|
songs.value = data.songs;
|
||||||
|
|
||||||
|
audioStore.setCollection(songs.value)
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<div class="wrapper">
|
||||||
|
<nav class="flex justify-start my-2 mx-1 space-x-1">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/menu/collections"><i
|
||||||
|
class="fa-solid fa-arrow-left"></i>
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="px-8 text-nowrap overflow-scroll absolute left-0 right-0 text-center"> {{ name }} </h1>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 flex-col h-full overflow-scroll">
|
||||||
|
|
||||||
|
<SongItem
|
||||||
|
v-for="(song, index) in songs"
|
||||||
|
:key="index"
|
||||||
|
:song="song"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
frontend/src/components/CollectionListItem.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
import type { CollectionPreview } from '@/script/types';
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import { useAudioStore } from '@/stores/audioStore';
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const audioStore = useAudioStore();
|
||||||
|
const props = defineProps<{ collection: CollectionPreview}>();
|
||||||
|
|
||||||
|
|
||||||
|
function hi(){
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<RouterLink @click.navtive="hi" :to="'/collection/' + props.collection.index">
|
||||||
|
<div class=" border border-pink-500 rounded-lg flex">
|
||||||
|
<img class="h-20 w-20 m-2 rounded-lg" :src="props.collection.previewimage" loading="lazy" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h3 class="self-start text-yellow-500">{{ props.collection.name }}</h3>
|
||||||
|
<h5 class="self-start text-yellow-500 text-sm">{{ props.collection.length }} Songs </h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
</template>
|
||||||
20
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<hr>
|
||||||
|
<nav class="flex justify-around my-4 text-xl">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl hover:text-pink-500" to="/me">
|
||||||
|
<img src="https://a.ppy.sh/14100399" class="h-12 rounded-full">
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
class="flex flex-col justify-center text-2xl p-4 rounded-full backdrop--light shadow-xl hover:text-pink-500"
|
||||||
|
to="/menu"><i class="fa-solid fa-house"></i>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
class="flex flex-col justify-center text-2xl p-4 rounded-full backdrop--light shadow-xl hover:text-pink-500"
|
||||||
|
to="/search">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
46
frontend/src/components/NowPlaying.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAudioStore } from '@/stores/audioStore';
|
||||||
|
|
||||||
|
const audioStore = useAudioStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<hr>
|
||||||
|
<div class="relative wrapper p-1 grow text-yellow-500">
|
||||||
|
|
||||||
|
<img :src="encodeURI(audioStore.bgimg)" class="absolute top-0 left-0 w-full h-full"
|
||||||
|
:style="{ 'filter': 'blur(2px)', 'opacity': '0.5' }" alt="Background Image" />
|
||||||
|
|
||||||
|
<nav class="relative flex justify-around my-2 z-10">
|
||||||
|
|
||||||
|
<div class=" grow flex flex-col justify-around text-3xl text-center" to="/menu">
|
||||||
|
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle ? 'text-pink-500' : '']"
|
||||||
|
class="fa-solid fa-shuffle"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex-col grow text-center justify-center hover:text-pink-500" @click="audioStore.togglePlay">
|
||||||
|
<i :class="[audioStore.isPlaying ? ' fa-circle-play' : 'fa-circle-pause']" class="text-7xl fa-regular"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grow flex flex-col justify-around text-3xl text-center hover:text-pink-500" to="/menu">
|
||||||
|
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat ? 'text-pink-500' : '']"
|
||||||
|
class="fa-solid fa-repeat "></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
<RouterLink class="absolute right-2 bottom-2" to="/nowplaying">
|
||||||
|
<i class="fa-solid fa-arrow-up"></i>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<marquee class="relative mx-16 text-2xl font-bold text-pink-500" behavior="scroll">{{ audioStore.artist }} - {{ audioStore.title
|
||||||
|
}}</marquee>
|
||||||
|
<audio controls class="hidden" id="audio-player" :src="audioStore.songSrc"
|
||||||
|
@timeupdate="audioStore.update"></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
37
frontend/src/components/SongItem.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, defineProps } from 'vue'
|
||||||
|
import { useAudioStore } from '@/stores/audioStore';
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import type { Song } from '@/script/types';
|
||||||
|
|
||||||
|
const props = defineProps<{ song: Song}>();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const audioStore = useAudioStore();
|
||||||
|
|
||||||
|
function updateSong(){
|
||||||
|
|
||||||
|
let updated = props.song;
|
||||||
|
audioStore.setSong(updated)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div @click="updateSong" class="m-2 border border-pink-500 rounded-lg flex">
|
||||||
|
<img class="h-16 w-16 m-1 rounded-lg" :src="encodeURI(props.song.previewimage)" loading="lazy" />
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h3 class="text-nowrap overflow-scroll">
|
||||||
|
<slot name="songName">{{ props.song.name }}</slot>
|
||||||
|
</h3>
|
||||||
|
<h5 class="text-yellow-500 text-sm">
|
||||||
|
<slot name="artist">{{ props.song.artist }}</slot>
|
||||||
|
</h5>
|
||||||
|
<h5 class="text-yellow-500 text-sm">
|
||||||
|
<slot name="length">{{ props.song.length }}</slot>
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
7
frontend/src/components/icons/IconCommunity.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
7
frontend/src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
19
frontend/src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
class="iconify iconify--mdi"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
14
frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
32
frontend/src/registerServiceWorker.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { register } from 'register-service-worker'
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||||
|
ready () {
|
||||||
|
console.log(
|
||||||
|
'App is being served from cache by a service worker.\n' +
|
||||||
|
'For more details, visit https://goo.gl/AFskqB'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
registered () {
|
||||||
|
console.log('Service worker has been registered.')
|
||||||
|
},
|
||||||
|
cached () {
|
||||||
|
console.log('Content has been cached for offline use.')
|
||||||
|
},
|
||||||
|
updatefound () {
|
||||||
|
console.log('New content is downloading.')
|
||||||
|
},
|
||||||
|
updated () {
|
||||||
|
console.log('New content is available; please refresh.')
|
||||||
|
},
|
||||||
|
offline () {
|
||||||
|
console.log('No internet connection found. App is running in offline mode.')
|
||||||
|
},
|
||||||
|
error (error) {
|
||||||
|
console.error('Error during service worker registration:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
69
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import MenuView from '../views/MenuView.vue'
|
||||||
|
import RecentView from '../views/RecentView.vue'
|
||||||
|
import FavouritView from '../views/FavouritView.vue'
|
||||||
|
import CollectionView from '../views/CollectionView.vue'
|
||||||
|
import NowPlayingView from '../views/NowPlayingView.vue'
|
||||||
|
import MeView from '../views/MeView.vue'
|
||||||
|
import SearchView from '../views/SearchView.vue'
|
||||||
|
import CollectionItem from '../components/CollectionItem.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: '',
|
||||||
|
component: MeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/menu',
|
||||||
|
name: 'menu',
|
||||||
|
component: MenuView,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'default',
|
||||||
|
component: RecentView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'recent',
|
||||||
|
name: 'recent',
|
||||||
|
component: RecentView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'favourites',
|
||||||
|
name: 'favourites',
|
||||||
|
component: FavouritView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'collections',
|
||||||
|
name: 'collections',
|
||||||
|
component: CollectionView
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/nowplaying',
|
||||||
|
name: 'nowplaying',
|
||||||
|
component: NowPlayingView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/me',
|
||||||
|
name: 'me',
|
||||||
|
component: MeView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
name: 'search',
|
||||||
|
component: SearchView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/collection/:id',
|
||||||
|
name: 'collection',
|
||||||
|
component: CollectionItem
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
17
frontend/src/script/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type Song = {
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
length: number;
|
||||||
|
url: string;
|
||||||
|
previewimage: string;
|
||||||
|
mapper: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectionPreview = {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
length: number;
|
||||||
|
previewimage: string;
|
||||||
|
};
|
||||||
|
|
||||||
156
frontend/src/stores/audioStore.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { Song, CollectionPreview } from '@/script/types';
|
||||||
|
|
||||||
|
export const useAudioStore = defineStore('audioStore', () => {
|
||||||
|
|
||||||
|
const songSrc = ref('https://cdn.pixabay.com/audio/2024/05/24/audio_46382ae035.mp3')
|
||||||
|
|
||||||
|
const artist = ref('Artist ');
|
||||||
|
const title = ref('Title ');
|
||||||
|
const bgimg = ref('https://assets.ppy.sh/beatmaps/2197744/covers/cover@2x.jpg?1722207959');
|
||||||
|
const hash = ref('0000');
|
||||||
|
|
||||||
|
const isPlaying = ref(true)
|
||||||
|
const duration = ref('0:00')
|
||||||
|
const currentTime = ref('0:00')
|
||||||
|
const percentDone = ref(0)
|
||||||
|
|
||||||
|
const shuffle = ref(false);
|
||||||
|
const repeat = ref(false);
|
||||||
|
|
||||||
|
const activeCollection = ref<Song[]>([]);
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||||
|
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||||
|
|
||||||
|
isPlaying.value = audio.paused;
|
||||||
|
|
||||||
|
let current_min = Math.round(audio.currentTime / 60);
|
||||||
|
let current_sec = (Math.round(audio.currentTime) % 60);
|
||||||
|
|
||||||
|
if (!isNaN(current_sec) && !isNaN(current_min)) {
|
||||||
|
currentTime.value = current_min + ':' + current_sec.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration_min = Math.round(audio.duration / 60);
|
||||||
|
let duration_sec = (Math.round(audio.duration) % 60);
|
||||||
|
|
||||||
|
if (!isNaN(duration_sec) && !isNaN(duration_min)) {
|
||||||
|
duration.value = duration_min + ':' + duration_sec.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let percent = (audio.currentTime / audio.duration) * 100
|
||||||
|
|
||||||
|
if (!isNaN(percent)) {
|
||||||
|
percentDone.value = percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.ended) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||||
|
|
||||||
|
if (repeat.value) {
|
||||||
|
audio.pause()
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shuffle.value) {
|
||||||
|
audio.pause()
|
||||||
|
|
||||||
|
setSong(activeCollection.value[Math.floor(activeCollection.value.length * Math.random())])
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTime() {
|
||||||
|
console.log('currenttime')
|
||||||
|
|
||||||
|
var audioslider = document.getElementById("audio-slider") as HTMLInputElement;
|
||||||
|
|
||||||
|
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||||
|
|
||||||
|
audio.currentTime = Math.round((audioslider.value / 100) * audio.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrev() {
|
||||||
|
let index = activeCollection.value.findIndex(s => s.hash == hash.value);
|
||||||
|
setSong(activeCollection.value[(index - 1) % activeCollection.value.length])
|
||||||
|
|
||||||
|
console.log('prev')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNext() {
|
||||||
|
let index = 0;
|
||||||
|
if (shuffle.value) {
|
||||||
|
index = Math.floor(activeCollection.value.length * Math.random())
|
||||||
|
|
||||||
|
} else {
|
||||||
|
index = activeCollection.value.findIndex(s => s.hash == hash.value);
|
||||||
|
|
||||||
|
}
|
||||||
|
setSong(activeCollection.value[Math.abs((index + 1) % activeCollection.value.length)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleShuffle() {
|
||||||
|
console.log('shuffle', !shuffle.value)
|
||||||
|
shuffle.value = !shuffle.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRepeat() {
|
||||||
|
console.log('repeat', !repeat.value)
|
||||||
|
repeat.value = !repeat.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSong(song: Song) {
|
||||||
|
console.log('setSong', song)
|
||||||
|
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||||
|
|
||||||
|
if (!audio.paused) {
|
||||||
|
audio.pause
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.src = song.url;
|
||||||
|
|
||||||
|
songSrc.value = song.url
|
||||||
|
|
||||||
|
artist.value = song.artist;
|
||||||
|
title.value = song.name;
|
||||||
|
bgimg.value = song.previewimage;
|
||||||
|
hash.value = song.hash;
|
||||||
|
|
||||||
|
console.log("bg", bgimg.value)
|
||||||
|
|
||||||
|
audio.addEventListener('canplaythrough', () => {
|
||||||
|
audio.play().catch(error => {
|
||||||
|
console.error('Playback error:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCollection(songs: Song[]) {
|
||||||
|
activeCollection.value = songs;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return { setCollection, songSrc, updateTime, artist, title, bgimg, shuffle, repeat, setSong, togglePlay, togglePrev, toggleNext, toggleRepeat, toggleShuffle, isPlaying, currentTime, duration, update, percentDone }
|
||||||
|
})
|
||||||
0
frontend/src/stores/headerSe.ts
Normal file
15
frontend/src/stores/headerStore.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useHeaderStore = defineStore('headerStore', () => {
|
||||||
|
const showheader = ref(true)
|
||||||
|
function show() {
|
||||||
|
showheader.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
showheader.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { showheader, show, hide }
|
||||||
|
})
|
||||||
90
frontend/src/stores/userStore.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Song, CollectionPreview } from '@/script/types';
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('userStore', () => {
|
||||||
|
const userId = ref(null)
|
||||||
|
const baseUrl = ref('https://localhost:7254/')
|
||||||
|
|
||||||
|
async function fetchSong(hash: string): Promise<Song> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}api/v1/songs/${hash}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Song = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch songs:', error);
|
||||||
|
return {
|
||||||
|
hash: "-1",
|
||||||
|
name: "song name",
|
||||||
|
artist: "artist name",
|
||||||
|
length: 0,
|
||||||
|
url: "song.mp3",
|
||||||
|
previewimage: "404.im5",
|
||||||
|
mapper: "map",
|
||||||
|
} as Song;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithCache<T>(cacheKey: string, url: string, cacheDuration: number = 24 * 60 * 60 * 1000): Promise<T> {
|
||||||
|
const cacheTimestampKey = `${cacheKey}_timestamp`;
|
||||||
|
|
||||||
|
const cachedData = localStorage.getItem(cacheKey);
|
||||||
|
const cachedTimestamp = localStorage.getItem(cacheTimestampKey);
|
||||||
|
|
||||||
|
if (cachedData && cachedTimestamp && (Date.now() - parseInt(cachedTimestamp)) < cacheDuration) {
|
||||||
|
console.log(`Returning cached data for ${cacheKey}`);
|
||||||
|
return JSON.parse(cachedData);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: T = await response.json();
|
||||||
|
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(data));
|
||||||
|
localStorage.setItem(cacheTimestampKey, Date.now().toString());
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch data from ${url}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchCollections(): Promise<CollectionPreview[]> {
|
||||||
|
const cacheKey = 'collections_cache';
|
||||||
|
const url = `${baseUrl.value}api/v1/collections/`;
|
||||||
|
return fetchWithCache<CollectionPreview[]>(cacheKey, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCollection(index: number): Promise<Song[]> {
|
||||||
|
const cacheKey = `collection_${index}_cache`;
|
||||||
|
const url = `${baseUrl.value}api/v1/collection/${index}`;
|
||||||
|
return fetchWithCache<Song[]>(cacheKey, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFavorites(limit: number, offset: number): Promise<Song[]> {
|
||||||
|
const cacheKey = `favorites_${limit}_${offset}_cache`;
|
||||||
|
const url = `${baseUrl.value}api/v1/recent?limit=${limit}&offset=${offset}`;
|
||||||
|
return fetchWithCache<Song[]>(cacheKey, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecent(limit: number, offset: number): Promise<Song[]> {
|
||||||
|
const cacheKey = `recent_${limit}_${offset}_cache`;
|
||||||
|
const url = `${baseUrl.value}api/v1/songs/recent?limit=${limit}&offset=${offset}`;
|
||||||
|
return fetchWithCache<Song[]>(cacheKey, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return { fetchSong, fetchCollections, fetchCollection, fetchRecent, fetchFavorites, userId, baseUrl }
|
||||||
|
})
|
||||||
32
frontend/src/views/CollectionView.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Song, CollectionPreview } from '../script/types'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import CollectionListItem from '../components/CollectionListItem.vue'
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const collections = ref<CollectionPreview[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const data = await userStore.fetchCollections();
|
||||||
|
data.forEach(song => {
|
||||||
|
song.previewimage = `${userStore.baseUrl}api/v1/images/${song.previewimage}`;
|
||||||
|
})
|
||||||
|
collections.value = data;
|
||||||
|
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="flex-1 text-center flex flex-col h-full overflow-scroll">
|
||||||
|
<div class="flex flex-col overflow-scroll">
|
||||||
|
<CollectionListItem
|
||||||
|
v-for="(collection, index) in collections"
|
||||||
|
:key="index"
|
||||||
|
:collection="collection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
9
frontend/src/views/FavouritView.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SongItem from '../components/SongItem.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="flex-1 flex-col overflow-scroll">
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
30
frontend/src/views/MeView.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
var input = document.getElementById("url-input") as HTMLAudioElement;
|
||||||
|
console.log(input.value)
|
||||||
|
userStore.baseUrl = input.value;
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<div class="wrapper">
|
||||||
|
<nav class="flex justify-start my-2 mx-1 space-x-1">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1">
|
||||||
|
<h1> Meeeeee </h1>
|
||||||
|
<input @change="update" type="text" id="url-input" :value="userStore.baseUrl" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
35
frontend/src/views/MenuView.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useHeaderStore } from '@/stores/headerStore';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
return route.path === path ? 'bg-blue-500 text-white' : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStore = useHeaderStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="flex-1 flex flex-col h-full overflow-scroll">
|
||||||
|
<header v-show="true">
|
||||||
|
<div class="wrapper">
|
||||||
|
<nav class="flex justify-start my-2 mx-1 space-x-1 overflow-x-scroll flex-nowrap text-nowrap">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/recent">Recently
|
||||||
|
added</RouterLink>
|
||||||
|
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/favourites">
|
||||||
|
Favorites</RouterLink>
|
||||||
|
<RouterLink :class="`p-1 rounded-full backdrop--light shadow-xl ${isActive('/')}`" to="/menu/collections">
|
||||||
|
Collections</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
67
frontend/src/views/NowPlayingView.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAudioStore } from '@/stores/audioStore';
|
||||||
|
|
||||||
|
const audioStore = useAudioStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<div class="wrapper">
|
||||||
|
<div class="relative">
|
||||||
|
<nav class="flex flex-1 justify-start my-2 mx-1 space-x-1">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i>
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="absolute left-0 right-0 text-center"> Now Playing </h1>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 flex justify-center text-center text-yellow-600">
|
||||||
|
<div class="flex flex-col justify-around">
|
||||||
|
<div class="relative">
|
||||||
|
<i class="relative p-36 fa-solid fa-play">
|
||||||
|
|
||||||
|
<img class="h-72 absolute top-4 left-0 bottom-0 right-0 bg-center bg-cover rounded-lg" :src="encodeURI(audioStore.bgimg)" :key="audioStore.bgimg" />
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div class="flex w-screen justify-around">
|
||||||
|
<i class="fa-solid fa-backward-step text-5xl self-center" @click="audioStore.togglePrev"></i>
|
||||||
|
<i :class="[audioStore.isPlaying ? 'fa-circle-play' : 'fa-circle-pause']" class="fa-regular text-7xl "
|
||||||
|
@click="audioStore.togglePlay"></i>
|
||||||
|
<i class="fa-solid fa-forward-step text-5xl self-center" @click="audioStore.toggleNext"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-1 justify-around">
|
||||||
|
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle ? 'text-pink-500' : '']"
|
||||||
|
class="fa-solid fa-shuffle"></i>
|
||||||
|
|
||||||
|
<div class="m-4 text-pink-500 max-w-1/2 overflow-idden">
|
||||||
|
<p>{{ audioStore.title }}</p>
|
||||||
|
<p>{{ audioStore.artist }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-between mb-4">
|
||||||
|
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat ? 'text-pink-500' : '']"
|
||||||
|
class="fa-solid fa-repeat"></i>
|
||||||
|
<i @click="this.$router.go(-1);" class="fa-solid fa-arrow-down"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<input
|
||||||
|
class="appearance-none mx-4 flex-1 bg-yellow-200 bg-opacity-20 accent-yellow-600 rounded-lg outline-none slider"
|
||||||
|
type="range" id="audio-slider" @change="audioStore.updateTime" max="100" :value="audioStore.percentDone">
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mx-4">
|
||||||
|
<span id="current-time" class="time">{{ audioStore.currentTime }}</span>
|
||||||
|
<span id="duration" class="time ">{{ audioStore.duration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
10
frontend/src/views/RecentView.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SongItem from '../components/SongItem.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<main class="flex-1 flex-col overflow-scroll">
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
19
frontend/src/views/SearchView.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header>
|
||||||
|
<div class="wrapper">
|
||||||
|
<nav class="flex justify-start my-2 mx-1 space-x-1">
|
||||||
|
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i>
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="absolute left-0 right-0 text-center"> Search </h1>
|
||||||
|
</nav>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 flex-col">
|
||||||
|
<h1> Search...</h1>
|
||||||
|
<input class="flex-1 border border-pink-500 accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg m-2 p-2" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
14
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node20/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||