From 389b3b9244b1d959f2379ae81a95ea4141df1245 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 11 Sep 2026 01:29:37 +0200 Subject: [PATCH] Add anonymous listening support and redesign web-player UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/launch.json | 17 + .../Configuration/GetPlaybackPublicConfig.cs | 18 + .../Responses/PlaybackPublicConfigResponse.cs | 7 + .../YouTube/GetYouTubeAlbum.cs | 3 - .../YouTube/ResolveYouTubeTrackStream.cs | 10 +- .../Musify.Api/Endpoints/ConfigEndpoints.cs | 28 + backend/Hosts/Musify.Api/Program.cs | 1 + .../Endpoints/ConfigEndpointsTests.cs | 25 + .../GetPlaybackPublicConfigTests.cs | 31 + web-player/package-lock.json | 76 +-- web-player/package.json | 4 +- web-player/src/app.d.ts | 1 + web-player/src/lib/api/schema.d.ts | 42 ++ .../src/lib/components/MobileHeader.svelte | 86 +-- .../src/lib/components/MobileNav.svelte | 21 +- web-player/src/lib/components/Sidebar.svelte | 112 ++-- web-player/src/lib/components/TopBar.svelte | 225 +++++++ .../src/lib/components/ui/Button.svelte | 2 +- .../src/lib/components/ui/EmptyState.svelte | 15 +- .../src/lib/components/ui/MixBentoTile.svelte | 6 +- web-player/src/lib/components/ui/Rail.svelte | 8 +- .../lib/components/ui/SectionHeading.svelte | 8 +- web-player/src/lib/config.ts | 4 + web-player/src/lib/server/api.ts | 12 +- web-player/src/lib/server/playbackConfig.ts | 22 + web-player/src/lib/server/youtube.ts | 37 +- web-player/src/lib/theme/theme.css | 28 +- web-player/src/lib/tracks.ts | 4 + web-player/src/lib/types.ts | 8 + .../routes/(app)/albums/[id]/+page.server.ts | 25 +- .../[source]/[externalId]/+page.server.ts | 17 +- .../src/routes/(app)/explore/+page.server.ts | 14 +- .../(app)/playlists/[id]/+page.server.ts | 27 +- web-player/src/routes/+layout.server.ts | 9 +- web-player/src/routes/+layout.svelte | 14 +- web-player/src/routes/+page.server.ts | 39 +- web-player/src/routes/+page.svelte | 562 ++++++++++++------ .../routes/api/tracks/[id]/stream/+server.ts | 10 +- .../[source]/[externalId]/stream/+server.ts | 10 +- web-player/src/routes/layout.css | 36 +- 40 files changed, 1172 insertions(+), 452 deletions(-) create mode 100644 .claude/launch.json create mode 100644 backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs create mode 100644 backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs create mode 100644 web-player/src/lib/components/TopBar.svelte create mode 100644 web-player/src/lib/server/playbackConfig.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..dd00e9b --- /dev/null +++ b/.claude/launch.json @@ -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 + } + ] +} diff --git a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs new file mode 100644 index 0000000..7e64167 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs @@ -0,0 +1,18 @@ +using Mediator; +using Musify.Application.Configuration.Responses; + +namespace Musify.Application.Configuration +{ + /// Reads the parts of that are safe to expose to an + /// unauthenticated client, so it can decide whether to gate the app behind sign-in at all. + public record GetPlaybackPublicConfigQuery : IQuery; + + public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) + : IQueryHandler + { + public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => + ValueTask.FromResult(new PlaybackPublicConfigResponse( + playbackConfiguration.AllowAnonymousListening, + playbackConfiguration.AnonymousFragmentSeconds)); + } +} diff --git a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs new file mode 100644 index 0000000..acdd696 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs @@ -0,0 +1,7 @@ +namespace Musify.Application.Configuration.Responses +{ + /// Whether listeners without a session can stream music at all. + /// How many seconds of a track an anonymous listener may hear. + /// Zero means no cap. Meaningless when is false. + public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); +} diff --git a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs b/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs index 26d2c42..f92b1fd 100644 --- a/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs +++ b/backend/Core/Musify.Application/YouTube/GetYouTubeAlbum.cs @@ -17,9 +17,6 @@ namespace Musify.Application.YouTube { public async ValueTask> 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; diff --git a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs index 8ee269c..0af4f4e 100644 --- a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs +++ b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs @@ -10,8 +10,6 @@ using Musify.Domain.Entities; namespace Musify.Application.YouTube { - /// Null for an anonymous request — allowed only when - /// is enabled. public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId) : ICommand>; @@ -26,7 +24,7 @@ namespace Musify.Application.YouTube { public async ValueTask> 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 { diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs new file mode 100644 index 0000000..ed4220a --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -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(); + + return app; + } + + private static async Task GetPlaybackPublicConfig( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + } +} diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index c892594..18e3e6d 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -39,6 +39,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() + .MapConfigEndpoints() .MapMixEndpoints() .MapPlayListEndpoints() .MapTrackEndpoints() diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs new file mode 100644 index 0000000..41ec394 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs @@ -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(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.False(body.AllowAnonymousListening); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs new file mode 100644 index 0000000..fb11260 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs @@ -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); + } + } +} diff --git a/web-player/package-lock.json b/web-player/package-lock.json index 67c7d44..32e04b0 100644 --- a/web-player/package-lock.json +++ b/web-player/package-lock.json @@ -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", diff --git a/web-player/package.json b/web-player/package.json index 35117fb..083d6c8 100644 --- a/web-player/package.json +++ b/web-player/package.json @@ -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", diff --git a/web-player/src/app.d.ts b/web-player/src/app.d.ts index bc8d183..ec220c8 100644 --- a/web-player/src/app.d.ts +++ b/web-player/src/app.d.ts @@ -9,6 +9,7 @@ declare global { interface PageData { section?: string | null; + allowAnonymousListening?: boolean; } } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index ae2c2fa..e77cb66 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -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; diff --git a/web-player/src/lib/components/MobileHeader.svelte b/web-player/src/lib/components/MobileHeader.svelte index a8be805..501c76a 100644 --- a/web-player/src/lib/components/MobileHeader.svelte +++ b/web-player/src/lib/components/MobileHeader.svelte @@ -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,43 +26,54 @@ Musify
- - - {#if menuOpen} -
(menuOpen = !menuOpen)} + aria-label="Tu cuenta" + aria-expanded={menuOpen} + class="flex items-center rounded-full" > - (menuOpen = false)} - /> - (menuOpen = false)} - /> -
-
- - -
+ {#if user.picture} + + {:else} + + {user.name.charAt(0)} + + {/if} + + + {#if menuOpen} +
+ (menuOpen = false)} + /> + (menuOpen = false)} + /> +
+
+ + +
+ {/if} + {:else} + + + {/if}
diff --git a/web-player/src/lib/components/MobileNav.svelte b/web-player/src/lib/components/MobileNav.svelte index 6d6e48e..ca717c1 100644 --- a/web-player/src/lib/components/MobileNav.svelte +++ b/web-player/src/lib/components/MobileNav.svelte @@ -1,17 +1,28 @@ + + + + diff --git a/web-player/src/lib/components/ui/Button.svelte b/web-player/src/lib/components/ui/Button.svelte index 5786907..8ddcf77 100644 --- a/web-player/src/lib/components/ui/Button.svelte +++ b/web-player/src/lib/components/ui/Button.svelte @@ -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' }; diff --git a/web-player/src/lib/components/ui/EmptyState.svelte b/web-player/src/lib/components/ui/EmptyState.svelte index 126d684..208549c 100644 --- a/web-player/src/lib/components/ui/EmptyState.svelte +++ b/web-player/src/lib/components/ui/EmptyState.svelte @@ -13,17 +13,22 @@ let { icon: Icon, title, description, class: klass = '', actions }: Props = $props(); -
+
{#if Icon} - - + + {/if} {#if title} -

{title}

+

{title}

{/if} {#if description} -

{description}

+

{description}

{/if} {#if actions}
{@render actions()}
diff --git a/web-player/src/lib/components/ui/MixBentoTile.svelte b/web-player/src/lib/components/ui/MixBentoTile.svelte index 209c558..f0a2af6 100644 --- a/web-player/src/lib/components/ui/MixBentoTile.svelte +++ b/web-player/src/lib/components/ui/MixBentoTile.svelte @@ -24,11 +24,13 @@ {#if tiles.length > 0} -
+
{#each tiles as item, i (i)} -
+
{@render children()}
diff --git a/web-player/src/lib/components/ui/SectionHeading.svelte b/web-player/src/lib/components/ui/SectionHeading.svelte index d7bc6df..e762c12 100644 --- a/web-player/src/lib/components/ui/SectionHeading.svelte +++ b/web-player/src/lib/components/ui/SectionHeading.svelte @@ -11,14 +11,16 @@ {#if actions} -
-

+
+

{title}

{@render actions()}
{:else} -

+

{title}

{/if} diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 718fff1..4b3098b 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -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; diff --git a/web-player/src/lib/server/api.ts b/web-player/src/lib/server/api.ts index 0b1fa62..aa709f0 100644 --- a/web-player/src/lib/server/api.ts +++ b/web-player/src/lib/server/api.ts @@ -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( diff --git a/web-player/src/lib/server/playbackConfig.ts b/web-player/src/lib/server/playbackConfig.ts new file mode 100644 index 0000000..735bb9d --- /dev/null +++ b/web-player/src/lib/server/playbackConfig.ts @@ -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 { + 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; + } +} diff --git a/web-player/src/lib/server/youtube.ts b/web-player/src/lib/server/youtube.ts index 58c801b..503695f 100644 --- a/web-player/src/lib/server/youtube.ts +++ b/web-player/src/lib/server/youtube.ts @@ -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 { 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, + accessToken: string | null | undefined, + limit: number, + seedQuery?: string, + userId?: string, + variant = 0 +): Promise { + 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 => album !== null && album !== undefined); + return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) }; +} diff --git a/web-player/src/lib/theme/theme.css b/web-player/src/lib/theme/theme.css index 0e1cb42..0201ff3 100644 --- a/web-player/src/lib/theme/theme.css +++ b/web-player/src/lib/theme/theme.css @@ -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; diff --git a/web-player/src/lib/tracks.ts b/web-player/src/lib/tracks.ts index 9497f00..54ad1c0 100644 --- a/web-player/src/lib/tracks.ts +++ b/web-player/src/lib/tracks.ts @@ -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; } diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts index 12dc7bb..00306a7 100644 --- a/web-player/src/lib/types.ts +++ b/web-player/src/lib/types.ts @@ -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; +} diff --git a/web-player/src/routes/(app)/albums/[id]/+page.server.ts b/web-player/src/routes/(app)/albums/[id]/+page.server.ts index a744836..34a4558 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/[id]/+page.server.ts @@ -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}', { - params: { - path: { userId: user.sub }, - query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE } - } - }) + 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 }; }; diff --git a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts index c51196e..25f4d38 100644 --- a/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts +++ b/web-player/src/routes/(app)/albums/external/[source]/[externalId]/+page.server.ts @@ -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}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } - }) + 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 = { diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index dfdd52f..368114a 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -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}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } - }); + 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', { diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index 78590e2..05f71ba 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -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}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } - }) + 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 ?? [] }; }; diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index 9e6807d..cbabb50 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -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 }; }; diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index 656d21c..266169b 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -1,11 +1,12 @@ @@ -148,106 +227,274 @@ -
-
-
-

{data.greeting}

-

| undefined)} +
+

+ + + +
+{/snippet} + +
+
+
+
+
+ +
+
+

+ {data.greeting} +

+

+ Tu música,
sin límites. +

+

+ Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio. +

+
+ + {#if heroTrack} + + {/if}
-
- {#if recentlyPlayed.length > 0} +
+ {#if topMusicTargets.length > 0}
- -
- {#each recentlyPlayed as track, i (track.id)} + + {#snippet actions()} + {@render railArrows(topMusicRail)} + {/snippet} + + + {#each topMusicTargets as target, i (targetId(target))} {/each} -
+
{/if} - {#if albums.length > 0} -
- - - {#each albums as album, i (album.id)} - openAlbumMenu(e, album.id)} + -
- {album.title} -
-
{albumCaption(album)}
-
+
+ {album.title} +
+
{albumCaption(album)}
+
+ {:else} + {@const album = entry.album} + openAlbumMenu(e, 'youtube', album.albumId)} + > + +
+ {album.title} +
+
{youtubeAlbumCaption(album)}
+
+ {/if} {/each}
- {/if} +
{#if mixes.length > 0}
@@ -279,141 +526,102 @@ {#each playlists as playlist, i (playlist.id)} - +
+
+
+ +
{playlist.name}
-
- {#if playlist.description} - {playlist.description} - {:else} - - {/if} -
+ {#if playlist.description} +
{playlist.description}
+ {/if}
{/each}
- + - Ver todas + Ver todas
{/if} - {#if newTargets.length > 0} + {#if recTargets.length > 0}
- - - {#each newTargets as target, i (targetId(target))} -
-
- {#if targetExplicit(target)} - +
+
+ {#if targetExplicit(target)} + + {/if} + + {targetTitle(target)} + +
+ {#if targetArtist(target)} +
{targetArtist(target)}
+ {/if} +
+ {#if targetDurationSeconds(target)} + + {fmtTime(targetDurationSeconds(target) ?? 0)} + {/if} - - {targetTitle(target)} - -
- {#if targetArtist(target)} -
{targetArtist(target)}
- {/if} - + + {/each} - +
{/if} - {#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0} - - {/if}
- - {#if contextMenu} { - 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.')); }; diff --git a/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts b/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts index d0033be..72e3eed 100644 --- a/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts +++ b/web-player/src/routes/api/tracks/external/[source]/[externalId]/stream/+server.ts @@ -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.')); }; diff --git a/web-player/src/routes/layout.css b/web-player/src/routes/layout.css index 6db5b0a..292033d 100644 --- a/web-player/src/routes/layout.css +++ b/web-player/src/routes/layout.css @@ -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 {