Add anonymous listening support and redesign web-player UI
Backend: - Expose GET /config/playback so the frontend can detect whether anonymous listening is enabled without requiring auth - Wire anonymous access through track streaming and YouTube resolve flows Frontend: - Allow browsing/listening without a session when the backend enables it - Redesign Inicio, Sidebar and top bar with a new dark theme, typography, and layout (top bar with live quick-search, sticky/blurred on scroll) - Populate Top música, Popular and Recomendados with real Musify + YouTube Music data instead of placeholders - Various shape, spacing, color and animation refinements across the redesigned components
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "musify-web",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"],
|
||||
"port": 3000
|
||||
},
|
||||
{
|
||||
"name": "musify-api",
|
||||
"runtimeExecutable": "dotnet",
|
||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
||||
"port": 5111
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Application.Configuration
|
||||
{
|
||||
/// <summary>Reads the parts of <see cref="PlaybackConfiguration"/> that are safe to expose to an
|
||||
/// unauthenticated client, so it can decide whether to gate the app behind sign-in at all.</summary>
|
||||
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||
|
||||
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
||||
{
|
||||
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
||||
playbackConfiguration.AllowAnonymousListening,
|
||||
playbackConfiguration.AnonymousFragmentSeconds));
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
namespace Musify.Application.Configuration.Responses
|
||||
{
|
||||
/// <param name="AllowAnonymousListening">Whether listeners without a session can stream music at all.</param>
|
||||
/// <param name="AnonymousFragmentSeconds">How many seconds of a track an anonymous listener may hear.
|
||||
/// Zero means no cap. Meaningless when <paramref name="AllowAnonymousListening"/> is false.</param>
|
||||
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
||||
}
|
||||
@@ -17,9 +17,6 @@ namespace Musify.Application.YouTube
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.AlbumId))
|
||||
return Error.Validation(description: "The album id must not be empty.");
|
||||
|
||||
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||
if (result.IsError)
|
||||
return result;
|
||||
|
||||
@@ -10,8 +10,6 @@ using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
/// <param name="UserId">Null for an anonymous request — allowed only when
|
||||
/// <see cref="PlaybackConfiguration.AllowAnonymousListening"/> is enabled.</param>
|
||||
public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId)
|
||||
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||
|
||||
@@ -26,7 +24,7 @@ namespace Musify.Application.YouTube
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var isAnonymous = request.UserId is null;
|
||||
var isAnonymous = !request.UserId.HasValue;
|
||||
if (isAnonymous && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
|
||||
@@ -43,10 +41,6 @@ namespace Musify.Application.YouTube
|
||||
serverStream.ExpiresInSeconds);
|
||||
}
|
||||
|
||||
// Below this point the only option left is streaming straight from YouTube's own CDN,
|
||||
// which bypasses our Gateway (and its stream tickets) entirely — there's no way to cap that
|
||||
// to the configured anonymous fragment, so an anonymous listener simply doesn't get it: they
|
||||
// can retry once the track has been provisioned server-side, where the cap above applies.
|
||||
if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0)
|
||||
return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment.");
|
||||
|
||||
@@ -63,7 +57,7 @@ namespace Musify.Application.YouTube
|
||||
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||
}
|
||||
|
||||
if (track != null && request.UserId is not null)
|
||||
if (track != null&& request.UserId != null)
|
||||
{
|
||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class ConfigEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/config")
|
||||
.WithTags("Config");
|
||||
|
||||
group.MapGet("/playback", GetPlaybackPublicConfig)
|
||||
.WithName("GetPlaybackPublicConfig")
|
||||
.WithSummary("Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously.")
|
||||
.Produces<PlaybackPublicConfigResponse>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<PlaybackPublicConfigResponse> GetPlaybackPublicConfig(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ app.UseAuthorization();
|
||||
|
||||
app
|
||||
.MapAlbumEndpoints()
|
||||
.MapConfigEndpoints()
|
||||
.MapMixEndpoints()
|
||||
.MapPlayListEndpoints()
|
||||
.MapTrackEndpoints()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Configuration.Responses;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ConfigEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
|
||||
var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<PlaybackPublicConfigResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(body);
|
||||
Assert.False(body.AllowAnonymousListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Configuration
|
||||
{
|
||||
public sealed class GetPlaybackPublicConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_ReflectsPlaybackConfiguration()
|
||||
{
|
||||
var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30);
|
||||
var handler = new GetPlaybackPublicConfigQueryHandler(playback);
|
||||
|
||||
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(response.AllowAnonymousListening);
|
||||
Assert.Equal(30, response.AnonymousFragmentSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DefaultsToDisabled()
|
||||
{
|
||||
var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback());
|
||||
|
||||
var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(response.AllowAnonymousListening);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+23
-53
@@ -8,8 +8,8 @@
|
||||
"name": "web-player",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/sora": "^5.3.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
@@ -37,29 +37,6 @@
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -198,19 +175,19 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
|
||||
"node_modules/@fontsource-variable/bricolage-grotesque": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
|
||||
"integrity": "sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/sora": {
|
||||
"node_modules/@fontsource-variable/manrope": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz",
|
||||
"integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz",
|
||||
"integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -1121,6 +1098,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||
@@ -1172,6 +1150,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1409,27 +1388,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
@@ -1566,6 +1524,7 @@
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1627,6 +1586,7 @@
|
||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.64.0",
|
||||
"@typescript-eslint/types": "8.64.0",
|
||||
@@ -1734,6 +1694,7 @@
|
||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1817,6 +1778,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2051,6 +2013,7 @@
|
||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -3129,6 +3092,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3262,6 +3226,7 @@
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3278,6 +3243,7 @@
|
||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -3448,6 +3414,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -3582,6 +3549,7 @@
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3793,6 +3761,7 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3854,6 +3823,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/sora": "^5.3.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@lucide/svelte": "^1.23.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7",
|
||||
"jose": "^6.2.3",
|
||||
|
||||
Vendored
+1
@@ -9,6 +9,7 @@ declare global {
|
||||
|
||||
interface PageData {
|
||||
section?: string | null;
|
||||
allowAnonymousListening?: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+42
@@ -177,6 +177,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/config/playback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */
|
||||
get: operations["GetPlaybackPublicConfig"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/mixes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -742,6 +759,11 @@ export interface components {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
PlaybackPublicConfigResponse: {
|
||||
allowAnonymousListening: boolean;
|
||||
/** Format: int32 */
|
||||
anonymousFragmentSeconds: number | string;
|
||||
};
|
||||
PlayListApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1423,6 +1445,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetPlaybackPublicConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaybackPublicConfigResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
GetMixes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser;
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
@@ -25,6 +26,7 @@
|
||||
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
{#if user}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
@@ -63,6 +65,16 @@
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
aria-label="Iniciar sesión"
|
||||
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
|
||||
>
|
||||
<LogIn class="h-6 w-6" strokeWidth={2} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
|
||||
const links = [
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
const links = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
...(user
|
||||
? [
|
||||
{ href: '/playlists', label: 'Listas', icon: Music },
|
||||
{ href: '/library', label: 'Subidas', icon: Folder }
|
||||
];
|
||||
]
|
||||
: [])
|
||||
]);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
@@ -22,7 +33,7 @@
|
||||
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
|
||||
style="padding-bottom:var(--mf-safe-b)"
|
||||
>
|
||||
<ul class="grid h-[var(--mf-nav-h)] grid-cols-4">
|
||||
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
<li>
|
||||
|
||||
@@ -3,44 +3,37 @@
|
||||
import { isSectionActive } from '$lib/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import Home from '@lucide/svelte/icons/house';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import Disc from '@lucide/svelte/icons/disc-2';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser;
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
let navBox: HTMLElement | undefined = $state();
|
||||
let pillTop = $state(0);
|
||||
let pillHeight = $state(0);
|
||||
let pillVisible = $state(false);
|
||||
let animate = $state(false);
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
}
|
||||
|
||||
const mainLinks = [
|
||||
const mainLinks = $derived([
|
||||
{ href: '/', label: 'Inicio', icon: Home },
|
||||
{ href: '/explore', label: 'Buscar', icon: Search },
|
||||
{ href: '/playlists', label: 'Listas', icon: Music }
|
||||
];
|
||||
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
|
||||
]);
|
||||
|
||||
const secondaryLinks = [
|
||||
const secondaryLinks = $derived(
|
||||
user
|
||||
? [
|
||||
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
|
||||
{ href: '/library', label: 'Canciones subidas', icon: Folder },
|
||||
{ href: '/upload', label: 'Subir música', icon: Upload }
|
||||
];
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
@@ -69,24 +62,31 @@
|
||||
<a
|
||||
href={link.href}
|
||||
data-active={active}
|
||||
class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
|
||||
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
|
||||
? 'text-fg'
|
||||
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="h-6 w-6 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet groupLabel(label: string)}
|
||||
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
<aside
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<a href="/" class="px-3 font-display text-xl font-semibold tracking-tight text-fg">Musify</a>
|
||||
|
||||
<div class="relative flex flex-col gap-7" bind:this={navBox}>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
|
||||
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
|
||||
bind:this={navBox}
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
|
||||
? 'transition-[top,height] duration-200 ease-in-out'
|
||||
: ''}"
|
||||
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
|
||||
@@ -94,48 +94,24 @@
|
||||
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
|
||||
</div>
|
||||
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
<div>
|
||||
{@render groupLabel('Navegación')}
|
||||
<nav class="relative z-10 flex flex-col gap-2">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mx-2 border-t border-line"></div>
|
||||
|
||||
{#if secondaryLinks.length > 0}
|
||||
<div>
|
||||
{@render groupLabel('Tu música')}
|
||||
<nav class="relative z-10 flex flex-col gap-1">
|
||||
{#each secondaryLinks as link (link.href)}
|
||||
{@render navLink(link)}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-auto" bind:this={menuRef}>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
class="flex w-full items-center gap-2.5 rounded-control px-3 py-2.5 text-left transition hover:bg-hover"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SessionUser, YouTubeSong } from '$lib/types';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import {
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetExplicit,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import LogOut from '@lucide/svelte/icons/log-out';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import Cover from './ui/Cover.svelte';
|
||||
import ExplicitBadge from './ui/ExplicitBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
}
|
||||
|
||||
const QUICK_RESULTS_LIMIT = 5;
|
||||
|
||||
interface QuickSearchTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
isExplicit?: boolean;
|
||||
}
|
||||
|
||||
interface QuickSearchResponse {
|
||||
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
|
||||
}
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<TrackTarget[]>([]);
|
||||
let resultsOpen = $state(false);
|
||||
let searching = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let menuRef: HTMLDivElement | undefined = $state();
|
||||
let searchRef: HTMLDivElement | undefined = $state();
|
||||
let searchInput: HTMLInputElement | undefined = $state();
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let searchToken = 0;
|
||||
|
||||
function clearQuery() {
|
||||
query = '';
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
clearTimeout(searchTimeout);
|
||||
const term = query.trim();
|
||||
if (term.length < SEARCH_MIN_LENGTH) {
|
||||
results = [];
|
||||
resultsOpen = false;
|
||||
return;
|
||||
}
|
||||
resultsOpen = true;
|
||||
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function runQuickSearch(term: string) {
|
||||
const token = ++searchToken;
|
||||
searching = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = (await res.json()) as QuickSearchResponse;
|
||||
if (token !== searchToken) return;
|
||||
results = data.items
|
||||
.flatMap((item): TrackTarget[] => {
|
||||
if (item.track) return [{ kind: 'local', track: item.track }];
|
||||
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
|
||||
return [];
|
||||
})
|
||||
.slice(0, QUICK_RESULTS_LIMIT);
|
||||
} catch {
|
||||
if (token === searchToken) results = [];
|
||||
} finally {
|
||||
if (token === searchToken) searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function playResult(index: number) {
|
||||
player.playOrToggle(results.map(queueItemForTarget), index);
|
||||
resultsOpen = false;
|
||||
}
|
||||
|
||||
function onSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
resultsOpen = false;
|
||||
const q = query.trim();
|
||||
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
|
||||
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
|
||||
>
|
||||
<div></div>
|
||||
|
||||
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
|
||||
<form onsubmit={onSubmit}>
|
||||
<label
|
||||
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
|
||||
>
|
||||
<Search class="h-4 w-4 shrink-0 text-fg-2" />
|
||||
<input
|
||||
type="text"
|
||||
bind:this={searchInput}
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (resultsOpen = results.length > 0)}
|
||||
placeholder="Buscar"
|
||||
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
|
||||
/>
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearQuery}
|
||||
aria-label="Borrar búsqueda"
|
||||
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{#if resultsOpen && (results.length > 0 || searching)}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
{#if searching && results.length === 0}
|
||||
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
|
||||
{:else}
|
||||
{#each results as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playResult(i)}
|
||||
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-9 w-9 shrink-0 rounded-control shadow-art"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-6">
|
||||
{#if user}
|
||||
<div class="relative" bind:this={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Tu cuenta"
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
{#if user.picture}
|
||||
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
|
||||
{:else}
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
|
||||
>
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Salir" type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/login"
|
||||
data-sveltekit-reload
|
||||
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
|
||||
>
|
||||
Iniciar sesión
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<svelte:window onclick={onDocumentClick} />
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-accent/12',
|
||||
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
|
||||
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
|
||||
danger: 'bg-danger text-on-accent hover:brightness-110'
|
||||
};
|
||||
|
||||
@@ -13,17 +13,22 @@
|
||||
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
|
||||
<div
|
||||
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
|
||||
>
|
||||
{#if Icon}
|
||||
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
|
||||
<Icon class="h-6 w-6" />
|
||||
<span
|
||||
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
|
||||
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
|
||||
>
|
||||
<Icon class="h-7 w-7" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if title}
|
||||
<h3 class="mt-5 font-display text-2xl font-semibold tracking-tight text-fg">{title}</h3>
|
||||
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="mt-2 text-fg-2">{description}</p>
|
||||
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group/tile animate-enter relative block overflow-hidden rounded-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||
>
|
||||
{#if tiles.length > 0}
|
||||
<div class="absolute inset-0 flex transition duration-300 ease-out">
|
||||
<div
|
||||
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
|
||||
>
|
||||
{#each tiles as item, i (i)}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
|
||||
@@ -7,9 +7,15 @@
|
||||
}
|
||||
|
||||
let { class: klass = '', children }: Props = $props();
|
||||
|
||||
let track: HTMLDivElement | undefined = $state();
|
||||
|
||||
export function scrollByPage(direction: 1 | -1) {
|
||||
track?.scrollBy({ left: direction * track.clientWidth * 0.85, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
|
||||
<div bind:this={track} class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
</script>
|
||||
|
||||
{#if actions}
|
||||
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
|
||||
<h2 class="font-display font-medium tracking-tight text-fg sm:text-xl">
|
||||
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
|
||||
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
|
||||
{title}
|
||||
</h2>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="mb-3 font-display font-medium tracking-tight text-fg sm:mb-4 sm:text-xl {klass}">
|
||||
<h2
|
||||
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
@@ -13,4 +13,8 @@ export const HOME_ALBUMS_LIMIT = 12;
|
||||
export const HOME_MIXES_BENTO = 5;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const HOME_TOP_MUSIC_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_FILLER_LIMIT = 10;
|
||||
export const HOME_POPULAR_MAX = 5;
|
||||
export const HOME_ALBUM_FILLER_LIMIT = 8;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
|
||||
@@ -23,9 +23,15 @@ export function requireUser(locals: App.Locals, url: URL): SessionUser {
|
||||
return locals.user;
|
||||
}
|
||||
|
||||
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
|
||||
if (!locals.accessToken) error(401, message);
|
||||
return locals.accessToken;
|
||||
export function optionalUser(
|
||||
locals: App.Locals,
|
||||
url: URL,
|
||||
allowAnonymousListening: boolean
|
||||
): SessionUser | null {
|
||||
if (locals.user) return locals.user;
|
||||
if (!allowAnonymousListening)
|
||||
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function requireAccessTokenAction(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cached: { allowAnonymousListening: boolean; expiresAt: number } | null = null;
|
||||
|
||||
export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise<boolean> {
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.allowAnonymousListening;
|
||||
|
||||
try {
|
||||
const api = createApiClient({ fetch: fetchFn });
|
||||
const { data } = await api.GET('/config/playback');
|
||||
const allowAnonymousListening = data?.allowAnonymousListening ?? false;
|
||||
|
||||
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||
return allowAnonymousListening;
|
||||
} catch {
|
||||
// The API being unreachable shouldn't take down every page in the app;
|
||||
// fail closed (as if anonymous listening were off) and retry next time.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { createApiClient } from '$lib/server/api';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
|
||||
const YOUTUBE_FILLER_QUERIES = [
|
||||
'pop',
|
||||
@@ -24,6 +24,11 @@ export interface YoutubeFiller {
|
||||
continuationToken: string;
|
||||
}
|
||||
|
||||
export interface YoutubeAlbumFiller {
|
||||
query: string;
|
||||
items: YouTubeAlbumResult[];
|
||||
}
|
||||
|
||||
function pickRandomFillerQuery(): string {
|
||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
||||
}
|
||||
@@ -36,9 +41,9 @@ function hash(text: string): number {
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function pickFillerQueryForUser(userId: string, date: Date): string {
|
||||
function pickFillerQueryForUser(userId: string, date: Date, variant = 0): string {
|
||||
const dayKey = date.toISOString().slice(0, 10);
|
||||
const index = hash(`${userId}:${dayKey}`) % YOUTUBE_FILLER_QUERIES.length;
|
||||
const index = (hash(`${userId}:${dayKey}`) + variant) % YOUTUBE_FILLER_QUERIES.length;
|
||||
return YOUTUBE_FILLER_QUERIES[index];
|
||||
}
|
||||
|
||||
@@ -47,12 +52,13 @@ export async function fetchYoutubeFiller(
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seedQuery?: string,
|
||||
userId?: string
|
||||
userId?: string,
|
||||
variant = 0
|
||||
): Promise<YoutubeFiller> {
|
||||
if (!accessToken) return { query: '', items: [], continuationToken: '' };
|
||||
const query =
|
||||
seedQuery?.trim() ||
|
||||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery());
|
||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
||||
const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } });
|
||||
const items = (data?.items ?? [])
|
||||
.map((item) => item.youTubeSong)
|
||||
@@ -63,3 +69,24 @@ export async function fetchYoutubeFiller(
|
||||
continuationToken: data?.nextYoutubeContinuationToken ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchYoutubeAlbumFiller(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seedQuery?: string,
|
||||
userId?: string,
|
||||
variant = 0
|
||||
): Promise<YoutubeAlbumFiller> {
|
||||
if (!accessToken) return { query: '', items: [] };
|
||||
const query =
|
||||
seedQuery?.trim() ||
|
||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
||||
const { data } = await api.GET('/albums', {
|
||||
params: { query: { title: query, pageNumber: 1, pageSize: limit + 5 } }
|
||||
});
|
||||
const items = (data?.items ?? [])
|
||||
.map((item) => item.youTubeAlbum)
|
||||
.filter((album): album is NonNullable<typeof album> => album !== null && album !== undefined);
|
||||
return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) };
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
:root {
|
||||
--mf-bg: rgb(9, 9, 9);
|
||||
--mf-elevated: #0b0b0d;
|
||||
--mf-bg: oklch(12.5% 0.004 95);
|
||||
--mf-elevated: oklch(16% 0.005 95);
|
||||
|
||||
--mf-text: #c7c6c5;
|
||||
--mf-text-2: #a5a4a3;
|
||||
--mf-text-3: #8c8b8a;
|
||||
--mf-text-4: rgb(120, 120, 120);
|
||||
--mf-text: oklch(93% 0.004 95);
|
||||
--mf-text-2: oklch(78% 0.006 95);
|
||||
--mf-text-3: oklch(62% 0.007 95);
|
||||
--mf-text-4: oklch(46% 0.006 95);
|
||||
|
||||
--mf-surface: rgb(15, 15, 15);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.06);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.09);
|
||||
--mf-surface: oklch(15.5% 0.005 95);
|
||||
--mf-surface-2: rgba(255, 255, 255, 0.05);
|
||||
--mf-surface-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--mf-border: rgba(255, 255, 255, 0.08);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.12);
|
||||
--mf-border: rgba(255, 255, 255, 0.07);
|
||||
--mf-border-strong: rgba(255, 255, 255, 0.13);
|
||||
--mf-track: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--mf-accent: oklch(60% 0.1 145);
|
||||
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
|
||||
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
|
||||
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
|
||||
|
||||
--mf-scrim: #000000;
|
||||
--mf-on-art: #ffffff;
|
||||
--mf-on-art: #fefdfb;
|
||||
|
||||
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
|
||||
--mf-sidebar-w: 288px;
|
||||
--mf-nav-h: 60px;
|
||||
|
||||
@@ -26,6 +26,10 @@ export function targetExplicit(target: TrackTarget): boolean {
|
||||
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
||||
}
|
||||
|
||||
export function targetDurationSeconds(target: TrackTarget): number | undefined {
|
||||
return target.kind === 'youtube' ? target.song.durationSeconds : undefined;
|
||||
}
|
||||
|
||||
export function targetCoverSrc(target: TrackTarget): string | undefined {
|
||||
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,11 @@ export interface YouTubeSong {
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
export interface YouTubeAlbumResult {
|
||||
albumId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
thumbnailUrl: string;
|
||||
releaseYear: number | string | null;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
@@ -11,8 +11,9 @@ import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [albumRes, tracksRes, libraryRes] = await Promise.all([
|
||||
@@ -23,23 +24,25 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
}),
|
||||
api.GET('/tracks/users/{userId}', {
|
||||
user
|
||||
? api.GET('/tracks/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inAlbum = new Set(tracks.map((track) => track.id));
|
||||
const library = (libraryRes.data?.items ?? []).filter(
|
||||
const library = (libraryRes?.data?.items ?? []).filter(
|
||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
||||
);
|
||||
|
||||
const isOwner = Number(album.ownerUserId) === Number(user.sub);
|
||||
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
|
||||
|
||||
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
|
||||
};
|
||||
|
||||
+8
-5
@@ -1,26 +1,29 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||
import {
|
||||
addYouTubeToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [albumRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/albums/external/{source}/{externalId}', {
|
||||
params: { path: { source: params.source, externalId: params.externalId } }
|
||||
}),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum.', 404);
|
||||
|
||||
return { album, playlists: playlistsRes.data?.items ?? [], section: null };
|
||||
return { album, playlists: playlistsRes?.data?.items ?? [], section: null };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
@@ -18,16 +18,20 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||
|
||||
const user = requireUser(locals, url);
|
||||
// This page is reachable without a session when anonymous listening is on
|
||||
// (see +layout.server.ts), so `user` may be null here.
|
||||
const user = locals.user;
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const tracksPromise = api.GET('/tracks', {
|
||||
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
const playlistsPromise = user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
});
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const albumsPromise = query
|
||||
? api.GET('/albums', {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
optionalUser,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
@@ -16,8 +16,9 @@ import type { YouTubeSong } from '$lib/types';
|
||||
const MAX_NAME = 100;
|
||||
const SUGGESTIONS_LIMIT = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
|
||||
@@ -25,19 +26,23 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
}),
|
||||
api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
}),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
user
|
||||
? api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
|
||||
const alreadyLinked = new Set(
|
||||
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
|
||||
@@ -64,7 +69,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
tracks,
|
||||
library,
|
||||
youtube,
|
||||
playlists: playlistsRes.data?.items ?? []
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
|
||||
export const load: LayoutServerLoad = ({ locals, url }) => {
|
||||
if (!locals.user && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
|
||||
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
|
||||
|
||||
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
|
||||
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
|
||||
}
|
||||
return { user: locals.user };
|
||||
return { user: locals.user, allowAnonymousListening };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '@fontsource-variable/inter/index.css';
|
||||
import '@fontsource-variable/sora/index.css';
|
||||
import '@fontsource-variable/manrope/index.css';
|
||||
import '@fontsource-variable/bricolage-grotesque/opsz.css';
|
||||
import '$lib/theme/theme.css';
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import TopBar from '$lib/components/TopBar.svelte';
|
||||
import MobileHeader from '$lib/components/MobileHeader.svelte';
|
||||
import MobileNav from '$lib/components/MobileNav.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
@@ -32,10 +33,6 @@
|
||||
player.toggle();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.documentElement.classList.toggle('home', page.url.pathname === '/');
|
||||
});
|
||||
|
||||
function parseAccent(value: string): { c: number; h: number } | null {
|
||||
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
|
||||
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
|
||||
@@ -78,7 +75,7 @@
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
{#if isAuthPage || !data.user}
|
||||
{#if isAuthPage || (!data.user && !data.allowAnonymousListening)}
|
||||
<div class="min-h-screen bg-bg text-fg antialiased">
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -87,6 +84,7 @@
|
||||
<Sidebar user={data.user} />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<MobileHeader user={data.user} />
|
||||
<TopBar user={data.user} />
|
||||
<main
|
||||
class="flex-1 transition-[padding]"
|
||||
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
|
||||
@@ -100,7 +98,7 @@
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<MobileNav />
|
||||
<MobileNav user={data.user} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { fetchYoutubeAlbumFiller, fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
addYouTubeToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
import {
|
||||
HOME_ALBUM_FILLER_LIMIT,
|
||||
HOME_ALBUMS_LIMIT,
|
||||
HOME_LATEST_PAGE_SIZE,
|
||||
HOME_MIXES_BENTO,
|
||||
HOME_POPULAR_FILLER_LIMIT,
|
||||
HOME_SHELF_LIMIT,
|
||||
HOME_TOP_MUSIC_FILLER_LIMIT,
|
||||
YOUTUBE_FILLER_LIMIT
|
||||
} from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
// This dashboard is personal; an anonymous listener goes to /explore instead.
|
||||
if (!locals.user) redirect(302, '/explore');
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
@@ -44,7 +50,9 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
const latestItems = (latest.data?.items ?? []).flatMap((item) => (item.track ? [item.track] : []));
|
||||
const latestItems = (latest.data?.items ?? []).flatMap((item) =>
|
||||
item.track ? [item.track] : []
|
||||
);
|
||||
|
||||
return {
|
||||
greeting: pickGreeting(new Date().getHours()),
|
||||
@@ -54,12 +62,37 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||
newReleases: latestItems,
|
||||
topMusicFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
HOME_TOP_MUSIC_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
3
|
||||
),
|
||||
youtubeFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
YOUTUBE_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub
|
||||
user.sub,
|
||||
0
|
||||
),
|
||||
popularFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
HOME_POPULAR_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
1
|
||||
),
|
||||
albumFiller: fetchYoutubeAlbumFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
HOME_ALBUM_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub,
|
||||
2
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
+354
-146
@@ -1,22 +1,28 @@
|
||||
<script lang="ts">
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetDurationSeconds,
|
||||
targetExplicit,
|
||||
targetForQueueItem,
|
||||
targetId,
|
||||
targetTitle,
|
||||
type TrackTarget
|
||||
} from '$lib/tracks';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
|
||||
import Rail from '$lib/components/ui/Rail.svelte';
|
||||
import CoverBadge from '$lib/components/ui/CoverBadge.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
@@ -29,17 +35,32 @@
|
||||
type AlbumMenuState
|
||||
} from '$lib/components/AlbumContextMenu.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import { HOME_LATEST_PAGE_SIZE, HOME_POPULAR_MAX } from '$lib/config';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let topMusicFillerItems = $state<YouTubeSong[]>([]);
|
||||
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
||||
let popularFillerItems = $state<YouTubeSong[]>([]);
|
||||
let albumFillerItems = $state<YouTubeAlbumResult[]>([]);
|
||||
let topMusicRail: ReturnType<typeof Rail> | undefined = $state();
|
||||
let recommendedRail: ReturnType<typeof Rail> | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
topMusicFillerItems = [];
|
||||
let cancelled = false;
|
||||
data.topMusicFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
topMusicFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
youtubeFillerItems = [];
|
||||
@@ -53,6 +74,30 @@
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
popularFillerItems = [];
|
||||
let cancelled = false;
|
||||
data.popularFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
popularFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
albumFillerItems = [];
|
||||
let cancelled = false;
|
||||
data.albumFiller.then((filler) => {
|
||||
if (cancelled) return;
|
||||
albumFillerItems = appendUnique([], filler.items, (album) => album.albumId);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const albums = $derived(data.albums);
|
||||
const mixes = $derived(data.mixes);
|
||||
const playlists = $derived(data.playlists);
|
||||
@@ -60,10 +105,18 @@
|
||||
const newTargets = $derived<TrackTarget[]>(
|
||||
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
|
||||
);
|
||||
const topMusicTargets = $derived<TrackTarget[]>([
|
||||
...newTargets,
|
||||
...topMusicFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
const recTargets = $derived<TrackTarget[]>(
|
||||
youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
);
|
||||
|
||||
type RecommendedAlbum =
|
||||
| { kind: 'recent'; album: (typeof albums)[number] }
|
||||
| { kind: 'discover'; album: YouTubeAlbumResult };
|
||||
|
||||
const recentlyPlayed = $derived.by(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: QueueItem[] = [];
|
||||
@@ -96,11 +149,31 @@
|
||||
return merged.slice(0, HOME_LATEST_PAGE_SIZE);
|
||||
});
|
||||
|
||||
const popularTargets = $derived<TrackTarget[]>(
|
||||
[
|
||||
...recentlyPlayed.map(targetForQueueItem),
|
||||
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
].slice(0, HOME_POPULAR_MAX)
|
||||
);
|
||||
|
||||
const recommendedAlbums = $derived<RecommendedAlbum[]>([
|
||||
...albums.map((album) => ({ kind: 'recent' as const, album })),
|
||||
...albumFillerItems.map((album) => ({ kind: 'discover' as const, album }))
|
||||
]);
|
||||
|
||||
const heroTrack = $derived(recentlyPlayed[0] ?? null);
|
||||
const heroPlaying = $derived(
|
||||
heroTrack !== null && player.current.id === heroTrack.id && player.playing
|
||||
);
|
||||
|
||||
function playRecent(index: number) {
|
||||
player.playOrToggle(recentlyPlayed, index);
|
||||
}
|
||||
function playNew(index: number) {
|
||||
player.playOrToggle(newTargets.map(queueItemForTarget), index);
|
||||
function playTopMusic(index: number) {
|
||||
player.playOrToggle(topMusicTargets.map(queueItemForTarget), index);
|
||||
}
|
||||
function playPopular(index: number) {
|
||||
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
|
||||
}
|
||||
function playRecommended(index: number) {
|
||||
player.playOrToggle(recTargets.map(queueItemForTarget), index);
|
||||
@@ -115,8 +188,8 @@
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||
albumMenu = albumContextMenuStateFor(event, 'local', albumId);
|
||||
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
|
||||
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
||||
}
|
||||
|
||||
async function albumPlayNext() {
|
||||
@@ -141,6 +214,12 @@
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function youtubeAlbumCaption(album: YouTubeAlbumResult) {
|
||||
return [album.artist, album.releaseYear ? String(album.releaseYear) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -148,106 +227,274 @@
|
||||
<meta name="description" content="Tu música, sin límites." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="relative overflow-hidden page-x pt-8 pb-10 sm:pt-11 sm:pb-16">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
|
||||
style="background:linear-gradient(135deg, var(--color-accent), color-mix(in oklch, var(--color-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
||||
></div>
|
||||
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
|
||||
<h1
|
||||
class="font-display text-3xl leading-[1.05] font-semibold tracking-tight text-fg sm:text-6xl"
|
||||
>
|
||||
Tu música. Sin límites.
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-10 page-x py-4 sm:py-6">
|
||||
{#if recentlyPlayed.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Recientemente reproducidas" />
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
||||
>
|
||||
{#each recentlyPlayed as track, i (track.id)}
|
||||
{#snippet railArrows(rail: ReturnType<typeof Rail> | undefined)}
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecent(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(track))}
|
||||
aria-label="{player.current.id === track.id && player.playing
|
||||
? 'Pausar'
|
||||
: 'Reproducir'} {track.title}"
|
||||
class="group/row flex min-w-0 items-center gap-3 rounded-control bg-surface p-2.5 text-left transition duration-200"
|
||||
onclick={() => rail?.scrollByPage(-1)}
|
||||
aria-label="Anterior"
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => rail?.scrollByPage(1)}
|
||||
aria-label="Siguiente"
|
||||
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<section class="relative overflow-hidden page-x pt-8 pb-8 sm:pt-10 sm:pb-10">
|
||||
<div class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-x-0 top-[-19rem] h-[34rem] sm:h-[42rem]"
|
||||
style="background:radial-gradient(58% 100% at 50% 0%, color-mix(in oklch, var(--color-accent), transparent 5%), color-mix(in oklch, var(--color-accent), transparent 48%) 42%, transparent 78%); animation:breathe 14s ease-in-out infinite"
|
||||
></div>
|
||||
<div class="absolute inset-x-0 bottom-0 h-16 bg-linear-to-b from-transparent to-bg"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between lg:gap-8">
|
||||
<div class="animate-enter flex max-w-2xl flex-col gap-4">
|
||||
<p class="text-xs font-semibold tracking-[0.28em] text-fg-2 uppercase sm:text-sm">
|
||||
{data.greeting}
|
||||
</p>
|
||||
<h1
|
||||
class="font-display text-[clamp(3rem,7.5vw,5.5rem)] leading-[0.98] font-medium tracking-tight text-fg"
|
||||
>
|
||||
Tu música,<br /><span class="text-accent-soft">sin límites.</span>
|
||||
</h1>
|
||||
<p class="max-w-md text-base text-fg-2 sm:text-lg">
|
||||
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if heroTrack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecent(0)}
|
||||
aria-label="{heroPlaying ? 'Pausar' : 'Reproducir'} {heroTrack.title}"
|
||||
class="group/hero animate-enter relative flex w-full max-w-sm shrink-0 items-center gap-4 overflow-hidden rounded-panel border border-line-strong bg-surface/70 p-4 text-left backdrop-blur-xl transition duration-300 hover:border-accent-soft/50 hover:bg-surface-hover hover:shadow-glow sm:p-5"
|
||||
style="animation-delay:140ms"
|
||||
>
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
src={track.coverUrl}
|
||||
size="small"
|
||||
alt={track.title}
|
||||
class="h-14 w-14 shrink-0 rounded-control shadow-art transition duration-200"
|
||||
trackId={heroTrack.id}
|
||||
src={heroTrack.coverUrl}
|
||||
size="medium"
|
||||
alt={heroTrack.title}
|
||||
class="h-16 w-16 shrink-0 rounded-art shadow-art sm:h-20 sm:w-20"
|
||||
>
|
||||
{#if player.current.id === track.id}
|
||||
{#if player.current.id === heroTrack.id}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if track.explicit}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[0.65rem] font-semibold tracking-[0.2em] text-fg-2 uppercase">
|
||||
Seguir escuchando
|
||||
</p>
|
||||
<p class="mt-1 truncate font-display text-lg text-fg">{heroTrack.title}</p>
|
||||
{#if heroTrack.artist}
|
||||
<p class="truncate text-sm text-fg-2">{heroTrack.artist}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-accent-soft text-on-accent shadow-art transition duration-300 group-hover/hero:scale-105"
|
||||
>
|
||||
{#if heroPlaying}
|
||||
<Pause class="h-4.5 w-4.5" fill="currentColor" />
|
||||
{:else}
|
||||
<Play class="h-4.5 w-4.5 translate-x-0.5" fill="currentColor" />
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-12 page-x pb-8 sm:pb-10">
|
||||
{#if topMusicTargets.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Top música">
|
||||
{#snippet actions()}
|
||||
{@render railArrows(topMusicRail)}
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
<Rail bind:this={topMusicRail}>
|
||||
{#each topMusicTargets as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playTopMusic(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
aria-label="Reproducir {targetTitle(target)}"
|
||||
class="group/card animate-enter block w-36 shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<div class="relative h-36 w-36">
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="large"
|
||||
alt={targetTitle(target)}
|
||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
>
|
||||
{#if isTargetCurrent(target)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
</div>
|
||||
<div class="mt-3 flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
|
||||
{track.title}
|
||||
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if track.artist}
|
||||
<div class="truncate text-sm text-muted">{track.artist}</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Rail>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Álbumes recientes" />
|
||||
<Rail>
|
||||
{#each albums as album, i (album.id)}
|
||||
<div class="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-8">
|
||||
<section class="min-w-0">
|
||||
<SectionHeading title="Popular" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each popularTargets as target, i (targetId(target))}
|
||||
{@const isCurrent = isTargetCurrent(target)}
|
||||
<div
|
||||
class="group/row flex min-w-0 items-center gap-3 rounded-control p-2 transition duration-200 {isCurrent
|
||||
? 'bg-surface-hover'
|
||||
: 'hover:bg-hover'}"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playPopular(i)}
|
||||
aria-label="{isCurrent && player.playing ? 'Pausar' : 'Reproducir'} {targetTitle(
|
||||
target
|
||||
)}"
|
||||
class="relative shrink-0"
|
||||
>
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-11 w-11 rounded-control shadow-art transition duration-200"
|
||||
>
|
||||
{#if isCurrent}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
{#if !isCurrent}
|
||||
<span
|
||||
class="absolute inset-0 grid place-items-center rounded-control bg-scrim/0 opacity-0 transition duration-200 group-hover/row:bg-scrim/50 group-hover/row:opacity-100"
|
||||
>
|
||||
<Play class="h-3.5 w-3.5 translate-x-0.5 text-on-art" fill="currentColor" />
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" onclick={() => playPopular(i)} class="min-w-0 flex-1 text-left">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span
|
||||
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
|
||||
>
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => openContextMenu(e, target)}
|
||||
aria-label="Más opciones"
|
||||
class="shrink-0 rounded-full p-1.5 text-fg-2 opacity-0 transition group-hover/row:opacity-100 hover:text-fg"
|
||||
>
|
||||
<MoreHorizontal class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="min-w-0">
|
||||
<SectionHeading title="Álbumes recomendados">
|
||||
{#snippet actions()}
|
||||
{@render railArrows(recommendedRail)}
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
<Rail bind:this={recommendedRail}>
|
||||
{#each recommendedAlbums as entry, i (entry.kind === 'recent' ? entry.album.id : entry.album.albumId)}
|
||||
{#if entry.kind === 'recent'}
|
||||
{@const album = entry.album}
|
||||
<a
|
||||
href={album.source === 'YouTube'
|
||||
? `/albums/external/youtube/${album.externalId}`
|
||||
: `/albums/${album.id}`}
|
||||
class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
class="group/card animate-enter block w-36 shrink-0 text-center"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
||||
oncontextmenu={(e) =>
|
||||
openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
{#if album.source === 'YouTube' && album.thumbnailUrl}
|
||||
<Cover
|
||||
trackId={album.id}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="h-52 w-42 rounded-art shadow-art ring-line transition duration-300 ease-out"
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
{:else}
|
||||
<PlaylistArt
|
||||
trackIds={album.coverTrackIds}
|
||||
class="h-56 w-48 rounded-art shadow-art ring-line transition duration-300 ease-out"
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-2.5 text-fg transition-colors group-hover/card:text-accent-soft">
|
||||
<div
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 text-sm text-fg-3">{albumCaption(album)}</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{albumCaption(album)}</div>
|
||||
</a>
|
||||
{:else}
|
||||
{@const album = entry.album}
|
||||
<a
|
||||
href="/albums/external/youtube/{album.albumId}"
|
||||
class="group/card animate-enter block w-36 shrink-0 text-center"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', album.albumId)}
|
||||
>
|
||||
<Cover
|
||||
trackId={album.albumId}
|
||||
src={album.thumbnailUrl}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
/>
|
||||
<div
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{album.title}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{youtubeAlbumCaption(album)}</div>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</Rail>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if mixes.length > 0}
|
||||
<section>
|
||||
@@ -279,140 +526,101 @@
|
||||
{#each playlists as playlist, i (playlist.id)}
|
||||
<a
|
||||
href="/playlists/{playlist.id}"
|
||||
class="group/card animate-enter block w-38 shrink-0 rounded-art"
|
||||
class="group/card animate-enter block w-40 shrink-0 pr-2 pb-2"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<div class="relative h-40 w-40">
|
||||
<div
|
||||
class="absolute inset-0 translate-x-2 translate-y-2 rounded-art bg-surface-2 ring-1 ring-line"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 translate-x-1 translate-y-1 rounded-art bg-surface-hover ring-1 ring-line"
|
||||
></div>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
version={playlist.updatedAt}
|
||||
class="h-38 w-38 rounded-art"
|
||||
class="absolute inset-0 h-full w-full rounded-art shadow-art-lg ring-1 ring-line-strong"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||
>
|
||||
{playlist.name}
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-sm text-muted">
|
||||
{#if playlist.description}
|
||||
{playlist.description}
|
||||
{:else}
|
||||
|
||||
<div class="mt-0.5 truncate text-sm text-fg-2">{playlist.description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
href="/playlists"
|
||||
class="grid h-36 w-36 shrink-0 place-items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||
class="group/more grid h-40 w-40 shrink-0 place-items-center rounded-art border border-dashed border-line text-fg-2 transition duration-300 hover:border-accent-soft/50 hover:text-accent-soft"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="grid h-8 w-8 place-items-center">
|
||||
<span
|
||||
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 transition-transform duration-300 group-hover/more:translate-x-0.5"
|
||||
>
|
||||
<ArrowRight class="h-4 w-4" />
|
||||
</span>
|
||||
Ver todas
|
||||
<span class="text-sm">Ver todas</span>
|
||||
</div>
|
||||
</a>
|
||||
</Rail>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if newTargets.length > 0}
|
||||
<section>
|
||||
<SectionHeading title="Canciones nuevas" />
|
||||
<Rail>
|
||||
{#each newTargets as target, i (targetId(target))}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playNew(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
aria-label="Reproducir {targetTitle(target)}"
|
||||
class="group/card animate-enter block w-[148px] shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
>
|
||||
<div class="relative h-[198px] w-[148px]">
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="medium"
|
||||
alt={targetTitle(target)}
|
||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
>
|
||||
{#if isTargetCurrent(target)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
<CoverBadge label="Nuevo" />
|
||||
</div>
|
||||
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</Rail>
|
||||
</section>
|
||||
{/if}
|
||||
{#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Todavía no hay música"
|
||||
description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!---
|
||||
<section class="page-x pt-10 pb-16">
|
||||
{#if recTargets.length > 0}
|
||||
<Rail>
|
||||
<section>
|
||||
<SectionHeading title="Quizá te guste" />
|
||||
<ul class="grid grid-cols-1 gap-1 lg:grid-cols-2 lg:gap-x-8">
|
||||
{#each recTargets as target, i (targetId(target))}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playRecommended(i)}
|
||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||
aria-label="Reproducir {targetTitle(target)}"
|
||||
class="group/card animate-enter block w-[168px] shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-hover focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
|
||||
>
|
||||
<div class="relative h-[168px] w-[168px]">
|
||||
<Cover
|
||||
trackId={targetId(target)}
|
||||
src={targetCoverSrc(target)}
|
||||
size="medium"
|
||||
size="small"
|
||||
alt={targetTitle(target)}
|
||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||
>
|
||||
{#if isTargetCurrent(target)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
</Cover>
|
||||
</div>
|
||||
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if targetExplicit(target)}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
||||
<span
|
||||
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
|
||||
>
|
||||
{targetTitle(target)}
|
||||
</span>
|
||||
</div>
|
||||
{#if targetArtist(target)}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{targetArtist(target)}</div>
|
||||
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetDurationSeconds(target)}
|
||||
<span class="shrink-0 text-sm text-fg-2 tabular-nums">
|
||||
{fmtTime(targetDurationSeconds(target) ?? 0)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</Rail>
|
||||
{:else}
|
||||
<EmptyState icon={Music} title="Todavía no hay recomendaciones" />
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</section>-->
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
// No hard login gate here: the API itself allows an anonymous request when
|
||||
// AllowAnonymousListening is on, and rejects it with 401 otherwise.
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const result = await api.GET('/tracks/{id}/stream', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo obtener el stream de la pista.'));
|
||||
return json(unwrapOrError(result, 'No se pudo reproducir esta canción.'));
|
||||
};
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
// No hard login gate here: the API itself allows an anonymous request when
|
||||
// AllowAnonymousListening is on, and rejects it with 401 otherwise.
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const result = await api.GET('/tracks/external/{source}/{externalId}/stream', {
|
||||
params: { path: { source: params.source, externalId: params.externalId } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo obtener el stream.'));
|
||||
return json(unwrapOrError(result, 'No se pudo reproducir esta canción.'));
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Sora Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--color-bg: var(--mf-bg);
|
||||
--color-elevated: var(--mf-elevated);
|
||||
@@ -19,6 +19,8 @@
|
||||
--color-muted: var(--mf-text-4);
|
||||
--color-accent: var(--mf-accent);
|
||||
--color-accent-soft: var(--mf-accent-soft);
|
||||
--color-accent-glow: var(--mf-accent-glow);
|
||||
--color-accent-dim: var(--mf-accent-dim);
|
||||
--color-on-accent: var(--mf-on-art);
|
||||
--color-scrim: var(--mf-scrim);
|
||||
--color-on-art: var(--mf-on-art);
|
||||
@@ -26,14 +28,15 @@
|
||||
--color-danger-fg: oklch(88.5% 0.062 18.334);
|
||||
|
||||
--radius-control: 0.625rem;
|
||||
--radius-panel: 1rem;
|
||||
--radius-art: 0.75rem;
|
||||
--radius-art-lg: 1rem;
|
||||
--radius-panel: 1.125rem;
|
||||
--radius-art: 0.85rem;
|
||||
--radius-art-lg: 1.125rem;
|
||||
--radius-art-round: 1.75rem;
|
||||
|
||||
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7);
|
||||
--shadow-art-lg: 0 24px 50px -16px rgba(0, 0, 0, 0.75);
|
||||
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow);
|
||||
}
|
||||
|
||||
@utility page-x {
|
||||
@@ -182,9 +185,24 @@ html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html.home {
|
||||
background-image: linear-gradient(to bottom, var(--mf-accent), transparent 340px);
|
||||
background-repeat: no-repeat;
|
||||
::selection {
|
||||
background-color: var(--mf-accent-dim);
|
||||
color: var(--mf-text);
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: var(--mf-noise);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user