feat: bring over the albums, explore and player work from the worktree

Moves the remaining uncommitted work: the home gets quick mixes and a
"Tus álbumes" shelf, saving a YouTube album now goes through
addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command,
and UserAlbum drops its unused ExternalId.

The mixes section keeps its current shape (no refresh action, no badge,
hidden when empty) rather than the older version the worktree carried.
This commit is contained in:
2026-07-21 18:18:23 +02:00
parent b75c97a696
commit 2f98982d83
29 changed files with 1742 additions and 406 deletions
+97 -108
View File
@@ -1,8 +1,6 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import Disc from '@lucide/svelte/icons/disc-3';
import Users from '@lucide/svelte/icons/users';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte';
@@ -12,12 +10,10 @@
import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
import { EXPLORE_ALBUM_SLOTS, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
import { hueFor } from '$lib/theme/color';
import type { YouTubeSong } from '$lib/types';
import TrackTile from '$lib/components/TrackTile.svelte';
@@ -25,6 +21,10 @@
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { appendUnique } from '$lib/collections';
import {
isTargetCurrent,
@@ -42,21 +42,11 @@
let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const filterOptions: ChipOption[] = [
{ value: 'tracks', label: 'Canciones', icon: Music },
{ value: 'albums', label: 'Álbumes', icon: Disc },
{ value: 'users', label: 'Usuarios', icon: Users, disabled: true, hint: 'Próximamente' }
];
let filters = $state<string[]>(['tracks', 'albums']);
const showTracks = $derived(filters.includes('tracks'));
const showAlbums = $derived(filters.includes('albums'));
let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number];
@@ -67,6 +57,7 @@
$effect(() => {
contextMenu = null;
albumMenu = null;
localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
@@ -99,54 +90,29 @@
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== ''));
const hasMore = $derived(localHasNext || ytContinuation !== '');
const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
type LocalAlbum = (typeof data.albums)[number];
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
let youtubeAlbums = $state<YouTubeAlbum[]>([]);
let ytAlbumsContinuation = $state('');
let loadingAlbums = $state(false);
$effect(() => {
youtubeAlbums = appendUnique([], data.youtubeAlbums, (album) => album.albumId);
ytAlbumsContinuation = data.youtubeAlbumsContinuation;
});
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
async function loadMoreAlbums() {
if (loadingAlbums || !ytAlbumsContinuation) return;
loadingAlbums = true;
try {
const params = new URLSearchParams({
query: data.query,
continuation: ytAlbumsContinuation
});
const res = await fetch(`/api/youtube/albums?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as {
items: YouTubeAlbum[];
continuationToken: string;
};
youtubeAlbums = appendUnique(youtubeAlbums, next.items, (album) => album.albumId);
ytAlbumsContinuation = next.continuationToken;
} catch {
ytAlbumsContinuation = '';
} finally {
loadingAlbums = false;
}
}
const nothingFound = $derived(
!!data.query &&
!data.ytError &&
(showTracks ? items.length === 0 : true) &&
(showAlbums ? !hasAlbums : true) &&
filters.length > 0
const albumEntries = $derived<AlbumEntry[]>(
[
...albums.map((album) => ({ kind: 'local' as const, album })),
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
].slice(0, EXPLORE_ALBUM_SLOTS)
);
const hasAlbums = $derived(albumEntries.length > 0);
const fillerCount = $derived(Math.max(0, EXPLORE_ALBUM_SLOTS - albumEntries.length));
const fillerTracks = $derived(items.slice(0, fillerCount));
const remainingTracks = $derived(items.slice(fillerCount));
const nothingFound = $derived(!!data.query && !data.ytError && items.length === 0 && !hasAlbums);
function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
@@ -177,6 +143,18 @@
contextMenu = null;
}
function isForeignAlbum(album: LocalAlbum) {
return String(album.ownerUserId) !== data.user?.sub;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId);
}
function closeAlbumMenu() {
albumMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
@@ -235,26 +213,10 @@
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..."
/>
<ChipGroup
label="Qué quieres buscar"
options={filterOptions}
selected={filters}
onChange={(next) => (filters = next)}
class="mt-4"
placeholder="¿Qué quieres reproducir?"
/>
</div>
{#if filters.length === 0}
<EmptyState
icon={Search}
title="No has elegido qué buscar"
description="Marca al menos un tipo de resultado para verlos aquí."
/>
{/if}
{#if form?.message}
<Alert class="mt-4">{form.message}</Alert>
{/if}
@@ -267,68 +229,91 @@
/>
{/if}
{#if showAlbums && hasAlbums}
{#if hasAlbums}
<div class="mt-8">
<SectionHeading title="Álbumes" />
<MediaGrid class="mt-4">
{#each albums as album, i (album.id)}
<AlbumCard
id={album.id}
title={album.title}
typeLabel="Álbum"
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
index={i}
/>
<MediaGrid as="ul" class="mt-4">
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<AlbumCard
id={entry.album.id}
title={entry.album.title}
typeLabel="Álbum"
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i}
onContextMenu={isForeignAlbum(entry.album)
? (e) => openAlbumMenu(e, 'local', entry.album.id)
: undefined}
/>
{:else}
<AlbumCard
id={entry.album.albumId}
href="/albums/youtube/{entry.album.albumId}"
title={entry.album.title}
typeLabel="Álbum"
subtitle={entry.album.artist}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/>
{/if}
</li>
{/each}
{#each youtubeAlbums as album, i (album.albumId)}
<AlbumCard
id={album.albumId}
href="/albums/youtube/{album.albumId}"
title={album.title}
typeLabel="Álbum"
subtitle={album.artist}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverSrc={album.thumbnailUrl}
index={albums.length + i}
{#each fillerTracks as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
typeLabel="Canción"
coverShape="round"
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={albumEntries.length + i}
onClick={() => togglePlay(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
{#if ytAlbumsContinuation}
<div class="mt-6 flex justify-center">
<Button variant="secondary" onclick={loadMoreAlbums} loading={loadingAlbums}>
Ver más álbumes
</Button>
</div>
{/if}
</div>
{/if}
{#if showTracks && items.length > 0}
{#if showAlbums && hasAlbums}
{#if remainingTracks.length > 0}
{#if hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (targetId(item))}
{#each remainingTracks as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
typeLabel="Canción"
coverShape="round"
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => togglePlay(i)}
onClick={() => togglePlay(fillerCount + i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
{/if}
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
@@ -348,3 +333,7 @@
onAddToQueue={addToQueue}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu menu={albumMenu} playlists={data.playlists} onClose={closeAlbumMenu} />
{/if}