Add anonymous listening support and redesign web-player UI

Backend:
- Expose GET /config/playback so the frontend can detect whether
  anonymous listening is enabled without requiring auth
- Wire anonymous access through track streaming and YouTube resolve flows

Frontend:
- Allow browsing/listening without a session when the backend enables it
- Redesign Inicio, Sidebar and top bar with a new dark theme, typography,
  and layout (top bar with live quick-search, sticky/blurred on scroll)
- Populate Top música, Popular and Recomendados with real Musify +
  YouTube Music data instead of placeholders
- Various shape, spacing, color and animation refinements across the
  redesigned components
This commit is contained in:
2026-09-11 01:29:37 +02:00
parent 10766bbb47
commit 389b3b9244
40 changed files with 1172 additions and 452 deletions
@@ -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 ?? []
};
};