Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
40 changed files with 1172 additions and 452 deletions
Showing only changes of commit 389b3b9244 - Show all commits
+17
View File
@@ -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));
}
}
@@ -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);
}
}
+1
View File
@@ -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);
}
}
}
+23 -53
View File
@@ -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",
+2 -2
View File
@@ -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",
+1
View File
@@ -9,6 +9,7 @@ declare global {
interface PageData {
section?: string | null;
allowAnonymousListening?: boolean;
}
}
}
+42
View File
@@ -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,43 +26,54 @@
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
<div class="relative" bind:this={menuRef}>
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
{#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 uppercase">
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
{#if user}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{#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 uppercase">
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</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>
+16 -5
View File
@@ -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 },
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
];
...(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>
+44 -68
View File
@@ -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 = [
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
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="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-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
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">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
<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>
<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>
{#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>
{/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} />
+225
View File
@@ -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 -1
View File
@@ -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}
+4
View File
@@ -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;
+9 -3
View File
@@ -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;
}
}
+32 -5
View File
@@ -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) };
}
+16 -12
View File
@@ -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;
+4
View File
@@ -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;
}
+8
View File
@@ -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}', {
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 };
};
@@ -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 = {
@@ -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', {
@@ -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 ?? []
};
};
+6 -3
View File
@@ -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 };
};
+6 -8
View File
@@ -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}
+36 -3
View File
@@ -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
)
};
};
+385 -177
View File
@@ -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"
{#snippet railArrows(rail: ReturnType<typeof Rail> | undefined)}
<div class="flex shrink-0 gap-2">
<button
type="button"
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"
>
Tu música. Sin límites.
</h1>
<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={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 === heroTrack.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<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-10 page-x py-4 sm:py-6">
{#if recentlyPlayed.length > 0}
<div class="flex flex-col gap-12 page-x pb-8 sm:pb-10">
{#if topMusicTargets.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)}
<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={() => 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={() => 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"
>
<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"
>
{#if player.current.id === track.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}
<ExplicitBadge />
<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}
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
{track.title}
</span>
</div>
{#if track.artist}
<div class="truncate text-sm text-muted">{track.artist}</div>
{/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/card:text-accent-soft">
{targetTitle(target)}
</span>
</div>
{#if targetArtist(target)}
<div class="mt-0.5 truncate text-sm text-fg-2">{targetArtist(target)}</div>
{/if}
</button>
{/each}
</div>
</Rail>
</section>
{/if}
{#if albums.length > 0}
<section>
<SectionHeading title="Álbumes recientes" />
<Rail>
{#each albums as album, i (album.id)}
<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"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, 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'}"
>
<div class="relative">
<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-36 shrink-0 text-center"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) =>
openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)}
>
{#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">
{album.title}
</div>
<div class="mt-0.5 text-sm text-fg-3">{albumCaption(album)}</div>
</a>
<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">{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,141 +526,102 @@
{#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"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
class="h-38 w-38 rounded-art"
/>
<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="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}
{/if}
</div>
{#if playlist.description}
<div class="mt-0.5 truncate text-sm text-fg-2">{playlist.description}</div>
{/if}
</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}
{#if recTargets.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]">
<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/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"
>
<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>
<CoverBadge label="Nuevo" />
</div>
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
<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 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}
</div>
{#if targetDurationSeconds(target)}
<span class="shrink-0 text-sm text-fg-2 tabular-nums">
{fmtTime(targetDurationSeconds(target) ?? 0)}
</span>
{/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>
</button>
</li>
{/each}
</Rail>
</ul>
</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>
{#each recTargets as target, i (targetId(target))}
<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"
>
<div class="relative h-[168px] w-[168px]">
<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>
</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>
{:else}
<EmptyState icon={Music} title="Todavía no hay recomendaciones" />
{/if}
</section>-->
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
@@ -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.'));
};
@@ -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.'));
};
+27 -9
View File
@@ -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 {