Remove YouTube integration from web-player; unify Home track rows

Drops every YouTube-backed route, proxy and type (external
albums/tracks, thumbnail proxy, youtube.ts) and simplifies
player/track/mix types to the local-only catalog now that the
backend contract dropped Source/ExternalId. Genre tiles on Explore
and "Populares esta semana" on Home stay as deliberate client-only
placeholders per product decision, just without the YouTube backing.

Also finishes the design-system unification pass: Home's three
hand-rolled track rows (Continuar escuchando, Playlist destacada,
Populares esta semana) now reuse the shared TrackTitleCell component
instead of duplicating cover+title+artist markup, and the orphaned
Rail.svelte (no importers) is removed.
This commit is contained in:
2026-09-15 21:17:14 +02:00
parent 31032bd8e8
commit 5ae9955bd0
41 changed files with 167 additions and 1541 deletions
@@ -38,9 +38,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
const tracks = tracksRes.data?.items ?? [];
const inAlbum = new Set(tracks.map((track) => track.id));
const library = (libraryRes?.data?.items ?? []).filter(
(track) => track.source === 'Local' && !inAlbum.has(track.id)
);
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id));
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
@@ -1,5 +1,5 @@
<script lang="ts">
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import { player, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
@@ -19,9 +19,7 @@
let editing = $state(false);
let confirmingDelete = $state(false);
const isCurrentQueue = $derived(
tracks.some((track) => queueIdForTrack(track) === player.current.id)
);
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id));
function playAll() {
if (tracks.length === 0) return;
@@ -1,12 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
queueIdForTrack,
toQueueItems,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
@@ -31,7 +26,7 @@
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
{#each tracks as track, i (track.id)}
{@const active = player.current.id === queueIdForTrack(track)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
@@ -1,32 +0,0 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import {
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
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 } }
}),
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 };
};
export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
};
@@ -1,179 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks';
let { data, form } = $props();
const album = $derived(data.album.album);
const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
const queue = $derived<QueueItem[]>(
tracks.map((track) => ({
id: track.videoId,
title: track.title,
artist: album.artist,
source: 'youtube' as const,
coverUrl: album.thumbnailUrl,
explicit: track.isExplicit
}))
);
const isCurrentQueue = $derived(tracks.some((track) => track.videoId === player.current.id));
function targetFor(index: number): TrackTarget {
const track = tracks[index];
return {
kind: 'youtube',
song: {
videoId: track.videoId,
title: track.title,
artist: album.artist,
album: album.title,
durationSeconds: Number(track.durationSeconds),
thumbnailUrl: album.thumbnailUrl,
isExplicit: track.isExplicit
}
};
}
function playFrom(index: number) {
player.playOrToggle(queue, index);
}
function playAll() {
if (queue.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queue, 0);
}
</script>
<svelte:head>
<title>{album.title}</title>
<meta name="description" content="Álbum {album.title} de {album.artist}," />
</svelte:head>
<Page>
<BackLink href="/explore" label="Volver a explorar" />
<CollectionHeader
eyebrow={album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
title={album.title}
description={album.artist}
meta={[
album.releaseYear ? String(album.releaseYear) : undefined,
`${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`,
fmtTime(Number(data.album.totalDurationSeconds))
]
.filter((part) => !!part)
.join(' · ')}
>
{#snippet cover()}
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="h-36 w-36 shrink-0 rounded-[18px] shadow-cover-lg sm:h-41 sm:w-41"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
{/snippet}
</CollectionHeader>
{#if form?.message}
<Alert tone="danger" class="mt-4">{form.message}</Alert>
{/if}
{#if data.album.description}
<p class="mt-6 text-sm text-fg-3">{data.album.description}</p>
{/if}
<TrackTable index class="mt-6 sm:mt-8">
{#each tracks as track, i (track.videoId)}
{@const active = player.current.id === track.videoId}
<TrackRow
{active}
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.videoId}
title={track.title}
artist={album.artist}
coverSrc={album.thumbnailUrl}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
</TrackRow>
{/each}
</TrackTable>
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={() => (contextMenu = null)}
onAddToQueue={() => {
const menu = contextMenu;
if (menu?.kind === 'youtube') {
const index = tracks.findIndex((track) => track.videoId === menu.song.videoId);
if (index >= 0) player.addToQueue(queue[index]);
}
contextMenu = null;
}}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
playlists={data.playlists}
onClose={() => (albumMenu = null)}
onPlayNext={() => {
player.playNext(queue);
albumMenu = null;
}}
onAddToQueue={() => {
player.appendToQueue(queue);
albumMenu = null;
}}
/>
{/if}
@@ -1,12 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -53,22 +47,16 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
const albumItems = albumsRes?.data?.items ?? [];
return {
query,
tracks,
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [],
genres: YOUTUBE_FILLER_QUERIES,
playlists: playlistsRes?.data?.items ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
addAlbumToPlaylist: addAlbumToPlaylistAction
};
+49 -142
View File
@@ -14,8 +14,7 @@
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
import type { YouTubeSong } from '$lib/types';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
@@ -29,7 +28,6 @@
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
@@ -43,6 +41,8 @@
user: 'Usuario'
};
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
// cliente y cada tile enlaza a una búsqueda normal por ese término.
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
@@ -60,23 +60,26 @@
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
let { data, form } = $props();
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
type SearchFilter = (typeof SEARCH_FILTERS)[number];
const SEARCH_GROUP_PREVIEW = 4;
let ytItems = $state<YouTubeSong[]>([]);
let ytUnavailable = $state(false);
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>;
type LocalTrack = (typeof data.tracks.items)[number]['track'];
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1);
let ytContinuation = $state('');
let hasMore = $state(false);
let loadingMore = $state(false);
@@ -84,63 +87,24 @@
contextMenu = null;
albumMenu = null;
sfilter = 'Todo';
localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : []));
localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber);
ytContinuation = data.tracks.nextYoutubeContinuationToken ?? '';
hasMore = data.tracks.hasNextPage;
if (data.query) {
ytItems = appendUnique(
[],
data.tracks.items.flatMap((item) =>
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
),
(i) => i.videoId
);
ytUnavailable = data.tracks.youtubeUnavailable;
} else {
ytItems = [];
ytUnavailable = false;
}
});
const genreTiles = $derived(
data.genres.map((query, i) => ({
query,
hue: Math.round((i * 360) / data.genres.length),
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
const songRows = $derived(
localItems.map((track) => ({
target: { track } satisfies TrackTarget,
seconds: Number(track.duration)
}))
);
const songRows = $derived([
...localItems.map((track) => ({
target: { kind: 'local' as const, track },
seconds: Number(track.duration)
})),
...ytItems.map((song) => ({
target: { kind: 'youtube' as const, song },
seconds: Number(song.durationSeconds)
}))
] satisfies { target: TrackTarget; seconds: number }[]);
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
const users = $derived(data.users);
type LocalAlbum = (typeof data.albums)[number];
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
const albumEntries = $derived<AlbumEntry[]>(
[
...albums.map((album) => ({ kind: 'local' as const, album })),
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
].slice(0, EXPLORE_ALBUMS_LIMIT)
);
const hasAlbums = $derived(albumEntries.length > 0);
const hasAlbums = $derived(albums.length > 0);
const hasUsers = $derived(users.length > 0);
const playlistMatches = $derived(
@@ -151,12 +115,12 @@
const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived(
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
);
const searchCounts = $derived({
Canciones: songRows.length,
Álbumes: albumEntries.length,
Álbumes: albums.length,
Playlists: playlistMatches.length,
Usuarios: users.length
});
@@ -172,7 +136,7 @@
type TopResult =
| { kind: 'track'; target: TrackTarget }
| { kind: 'album'; entry: AlbumEntry }
| { kind: 'album'; album: (typeof albums)[number] }
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
| { kind: 'user'; user: (typeof users)[number] };
@@ -185,14 +149,14 @@
if (userHit) return { kind: 'user', user: userHit };
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
if (trackHit) return { kind: 'track', target: trackHit };
const albumHit = albumEntries.find((entry) => startsWithQuery(entry.album.title));
if (albumHit) return { kind: 'album', entry: albumHit };
const albumHit = albums.find((album) => startsWithQuery(album.title));
if (albumHit) return { kind: 'album', album: albumHit };
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
if (users.length) return { kind: 'user', user: users[0] };
if (items.length) return { kind: 'track', target: items[0] };
if (albumEntries.length) return { kind: 'album', entry: albumEntries[0] };
if (albums.length) return { kind: 'album', album: albums[0] };
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
return null;
});
@@ -216,11 +180,7 @@
const topResultHref = $derived.by(() => {
if (!topResult || topResult.kind === 'track') return undefined;
if (topResult.kind === 'album') {
return topResult.entry.kind === 'local'
? `/albums/${topResult.entry.album.id}`
: `/albums/external/youtube/${topResult.entry.album.albumId}`;
}
if (topResult.kind === 'album') return `/albums/${topResult.album.id}`;
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
return `/u/${topResult.user.id}`;
});
@@ -233,22 +193,22 @@
contextMenu = contextMenuStateFor(event, target);
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId);
function openAlbumMenu(event: MouseEvent, albumId: string) {
albumMenu = albumContextMenuStateFor(event, albumId);
}
async function albumPlayNext() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
const { albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(kind, albumId));
player.playNext(await fetchAlbumQueueItems(albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
const { albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
player.appendToQueue(await fetchAlbumQueueItems(albumId));
}
function addToQueue() {
@@ -265,25 +225,16 @@
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) {
params.set('name', data.query);
if (ytContinuation) params.set('youtubeContinuationToken', ytContinuation);
}
if (data.query) params.set('name', data.query);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(
localItems,
next.items.flatMap((item) => (item.track ? [item.track] : [])),
next.items.map((item) => item.track),
(track) => track.id
);
ytItems = appendUnique(
ytItems,
next.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
(song) => song.videoId
);
localPage = Number(next.pageNumber);
ytContinuation = next.nextYoutubeContinuationToken ?? '';
hasMore = next.hasNextPage;
} catch {
hasMore = false;
@@ -346,26 +297,15 @@
{#if topResult.kind === 'track'}
<Cover
trackId={targetId(topResult.target)}
src={targetCoverSrc(topResult.target)}
size="large"
alt={targetTitle(topResult.target)}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else if topResult.kind === 'album'}
{#if topResult.entry.kind === 'local'}
<PlaylistArt
trackIds={topResult.entry.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else}
<Cover
trackId={topResult.entry.album.albumId}
src={topResult.entry.album.thumbnailUrl}
size="large"
alt={topResult.entry.album.title}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{/if}
<PlaylistArt
trackIds={topResult.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
/>
{:else if topResult.kind === 'playlist'}
<PlaylistArt
playlistId={topResult.playlist.id}
@@ -401,7 +341,7 @@
{topResult.kind === 'track'
? targetTitle(topResult.target)
: topResult.kind === 'album'
? topResult.entry.album.title
? topResult.album.title
: topResult.kind === 'playlist'
? topResult.playlist.name
: topResult.user.name}
@@ -410,10 +350,6 @@
<div class="truncate text-sm leading-none text-fg-3">
{targetArtist(topResult.target)}
</div>
{:else if topResult.kind === 'album' && topResult.entry.kind === 'youtube' && topResult.entry.album.artist}
<div class="truncate text-sm leading-none text-fg-3">
{topResult.entry.album.artist}
</div>
{/if}
</div>
@@ -517,7 +453,6 @@
{#snippet art(artClass)}
<Cover
trackId={targetId(item)}
src={targetCoverSrc(item)}
size="small"
alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset"
@@ -535,7 +470,7 @@
{#if sfilter === 'Canciones' && hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
{:else if !ytUnavailable}
{:else}
<EmptyState
icon={Music}
description="No hay canciones que coincidan con «{data.query}»."
@@ -556,40 +491,19 @@
{/snippet}
</SectionHeading>
<ul class="flex flex-col gap-1">
{#each capped(albumEntries) as entry (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
{#each capped(albums) as album (album.id)}
<li>
{#if entry.kind === 'local'}
<SearchResultRow
kind="album"
title={entry.album.title}
meta="{Number(entry.album.trackCount)} canciones"
href="/albums/{entry.album.id}"
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
>
{#snippet art(artClass)}
<PlaylistArt trackIds={entry.album.coverTrackIds} class={artClass} />
{/snippet}
</SearchResultRow>
{:else}
<SearchResultRow
kind="album"
title={entry.album.title}
subtitle={entry.album.artist}
meta={entry.album.releaseYear ? String(entry.album.releaseYear) : undefined}
href="/albums/external/youtube/{entry.album.albumId}"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
>
{#snippet art(artClass)}
<Cover
trackId={entry.album.albumId}
src={entry.album.thumbnailUrl}
size="small"
alt={entry.album.title}
class={artClass}
/>
{/snippet}
</SearchResultRow>
{/if}
<SearchResultRow
kind="album"
title={album.title}
meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}"
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
>
{#snippet art(artClass)}
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} />
{/snippet}
</SearchResultRow>
</li>
{/each}
</ul>
@@ -665,13 +579,6 @@
{/if}
</div>
{/if}
{#if ytUnavailable}
<EmptyState
icon={Music}
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
/>
{/if}
</Page>
{#if contextMenu}
@@ -23,8 +23,6 @@
id: t.id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
}))
);
@@ -97,7 +95,6 @@
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={track.coverUrl}
explicit={track.explicit}
{active}
onClick={() => playFrom(i)}
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { addTrackAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
@@ -23,6 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
addTrack: addTrackAction
};
@@ -25,7 +25,6 @@
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetId,
targetTitle
} from '$lib/tracks';
@@ -107,8 +106,6 @@
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
coverSrc={targetCoverSrc(targets[i])}
explicit={item.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import { player, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
@@ -17,7 +17,7 @@
let editing = $state(false);
let confirmingDelete = $state(false);
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
function playAll() {
if (tracks.length === 0) return;
@@ -1,16 +1,8 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import Badge from '$lib/components/ui/Badge.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
@@ -41,7 +33,7 @@
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === queueIdForTrack(track)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
<TrackTitleCell
@@ -49,21 +41,10 @@
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<Badge tone="danger">Error</Badge>
{:else if isPendingYouTubeTrack(track)}
<Badge tone="accent">Descargando</Badge>
{/if}
{/snippet}
</TrackTitleCell>
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
+4 -17
View File
@@ -1,12 +1,10 @@
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 { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { addTrackAction } from '$lib/server/playlistActions';
import {
HOME_LATEST_PAGE_SIZE,
HOME_MIXES_LIMIT,
HOME_POPULAR_FILLER_LIMIT,
HOME_SHELF_LIMIT,
HOME_SPOTLIGHT_TRACKS_LIMIT
} from '$lib/config';
@@ -43,9 +41,7 @@ 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 ?? []).map((item) => item.track);
const spotlightPlaylist = playlistItems[0] ?? null;
const spotlightTracksPromise = spotlightPlaylist
@@ -72,19 +68,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
newReleases: latestItems,
spotlightPlaylist,
spotlightTracks: spotlightTracksPromise,
listeningStats: listeningStatsPromise,
popularFiller: fetchYoutubeFiller(
api,
locals.accessToken,
HOME_POPULAR_FILLER_LIMIT,
undefined,
user.sub,
1
)
listeningStats: listeningStatsPromise
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
addTrack: addTrackAction
};
+37 -100
View File
@@ -10,8 +10,6 @@
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetDurationSeconds,
targetExplicit,
targetForQueueItem,
targetId,
@@ -19,12 +17,11 @@
targetTitle,
type TrackTarget
} from '$lib/tracks';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -32,36 +29,26 @@
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
let { data } = $props();
let contextMenu = $state<ContextMenuState | null>(null);
let popularFillerItems = $state<YouTubeSong[]>([]);
let spotlightItems = $state<TrackTarget[]>([]);
type SpotlightRow = { target: TrackTarget; seconds: number };
let spotlightRows = $state<SpotlightRow[]>([]);
let spotlightTotal = $state(0);
$effect(() => {
popularFillerItems = [];
let cancelled = false;
data.popularFiller.then((filler) => {
if (cancelled) return;
popularFillerItems = appendUnique([], filler.items, (song) => song.videoId);
});
return () => {
cancelled = true;
};
});
$effect(() => {
spotlightItems = [];
spotlightRows = [];
spotlightTotal = 0;
let cancelled = false;
data.spotlightTracks.then(({ items, totalCount }) => {
if (cancelled) return;
spotlightItems = items.map((track) => ({ kind: 'local' as const, track }));
spotlightRows = items.map((track) => ({
target: { track },
seconds: Number(track.duration)
}));
spotlightTotal = totalCount;
});
return () => {
@@ -72,10 +59,9 @@
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const spotlight = $derived(data.spotlightPlaylist);
const spotlightItems = $derived(spotlightRows.map((row) => row.target));
const spotlightDurationSeconds = $derived(
spotlightItems.some((item) => targetDurationSeconds(item) !== undefined)
? spotlightItems.reduce((sum, item) => sum + (targetDurationSeconds(item) ?? 0), 0)
: undefined
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
);
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
const spotlightVisibilityLabel = $derived(
@@ -86,9 +72,7 @@
? spotlightVisibilityLabel
: [
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
spotlightDurationSeconds !== undefined
? fmtDurationLong(spotlightDurationSeconds)
: '0 min',
fmtDurationLong(spotlightDurationSeconds),
spotlightVisibilityLabel
].join(' · ')
);
@@ -97,10 +81,7 @@
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTargets = $derived<TrackTarget[]>(
[
...recentPool.map(targetForQueueItem),
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
].slice(0, HOME_POPULAR_MAX)
recentPool.map(targetForQueueItem).slice(0, HOME_POPULAR_MAX)
);
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
@@ -208,42 +189,23 @@
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="button"
tabindex="0"
onclick={() => playContinue(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playContinue(i);
}}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent
class="animate-enter rounded-[13px] p-2.25 pr-3.5 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
style="animation-delay:{Math.min(i, 10) * 40}ms"
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
>
<Cover
<TrackTitleCell
trackId={item.id}
src={item.coverUrl}
size="small"
alt={item.title}
class="h-13 w-13 shrink-0 rounded-[10px]"
title={item.title}
artist={item.artist}
ownerUserId={item.ownerUserId}
coverSize="h-13 w-13"
active={isCurrent}
onClick={() => playContinue(i)}
/>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm leading-[1.3] font-medium {isCurrent
? 'text-accent-soft'
: 'text-fg'}"
>
{item.title}
</div>
<ArtistLink
name={item.artist || '—'}
ownerUserId={item.ownerUserId}
class="mt-0.75 text-xs text-fg-3"
/>
</div>
</div>
{/each}
</div>
@@ -301,8 +263,7 @@
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
>
{#each spotlightItems as target, i (targetId(target))}
{@const durationSeconds = targetDurationSeconds(target)}
{#each spotlightRows as { target, seconds }, i (targetId(target))}
<div
role="button"
tabindex="0"
@@ -333,9 +294,7 @@
class="mt-0.5 text-xs text-fg-3"
/>
</div>
<span class="shrink-0 text-xs text-muted tabular-nums">
{durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
</span>
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
</div>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
@@ -365,27 +324,17 @@
<!-- POPULARES ESTA SEMANA -->
<section>
<SectionHeading
title="Populares esta semana"
subtitle="Lo más escuchado entre quienes comparten tu gusto"
/>
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
{#if popularTargets.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTargets as target, i (targetId(target))}
{@const isCurrent = isTargetCurrent(target)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="button"
tabindex="0"
onclick={() => playPopular(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playPopular(i);
}}
oncontextmenu={(e) => openContextMenu(e, target)}
class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent
class="flex items-center gap-3.5 rounded-[11px] p-2 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
oncontextmenu={(e) => openContextMenu(e, target)}
>
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
@@ -394,28 +343,16 @@
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</span>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9.5 w-9.5 shrink-0 rounded-[8px]"
/>
<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 font-medium {isCurrent ? 'text-accent-soft' : 'text-fg'}"
>
{targetTitle(target)}
</span>
</div>
<ArtistLink
name={targetArtist(target)}
<TrackTitleCell
trackId={targetId(target)}
title={targetTitle(target)}
artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-xs text-fg-3"
coverSize="h-9.5 w-9.5"
explicit={targetExplicit(target)}
active={isCurrent}
onClick={() => playPopular(i)}
/>
</div>
</div>
@@ -1,12 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/albums/external/{source}/{externalId}', {
params: { path: { source: params.source, externalId: params.externalId } }
});
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
};
@@ -1,31 +0,0 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { loadThumbnail, parseThumbnailUrl } from '$lib/server/thumbnails';
import { thumbnailConfig } from '$lib/server/config';
import { THUMBNAIL_SIZES } from '$lib/thumbnails';
export const GET: RequestHandler = async ({ url, request, setHeaders }) => {
const source = parseThumbnailUrl(url.searchParams.get('u') ?? '');
if (!source) error(400, 'Miniatura no permitida.');
const requested = Number(url.searchParams.get('size'));
const size =
Number.isInteger(requested) && requested > 0
? Math.min(requested, thumbnailConfig.maxSize)
: THUMBNAIL_SIZES.large;
const image = await loadThumbnail(source, size);
if (!image) error(404, 'Miniatura no disponible.');
setHeaders({
'Cache-Control': `public, max-age=${thumbnailConfig.maxAgeSeconds}, immutable`,
'Content-Type': image.contentType,
ETag: image.etag
});
if (request.headers.get('if-none-match') === image.etag) {
return new Response(null, { status: 304 });
}
return new Response(image.body);
};
+1 -2
View File
@@ -7,11 +7,10 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const youtubeContinuationToken = url.searchParams.get('youtubeContinuationToken') || undefined;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', {
params: { query: { name, pageNumber, pageSize, youtubeContinuationToken } }
params: { query: { name, pageNumber, pageSize } }
});
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
@@ -1,14 +0,0 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
// 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 reproducir esta canción.'));
};