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:
@@ -1,12 +1,9 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
addYouTubeToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
@@ -26,27 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
save: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const {
|
||||
data,
|
||||
error: err,
|
||||
response
|
||||
} = await api.POST('/albums/youtube/{albumId}', {
|
||||
params: { path: { albumId: params.albumId } }
|
||||
});
|
||||
|
||||
if (response?.status === 409) {
|
||||
return fail(409, { message: 'Ese álbum ya está en tu biblioteca.' });
|
||||
}
|
||||
if (err || !data) {
|
||||
return fail(502, { message: 'No se pudo guardar el álbum.' });
|
||||
}
|
||||
|
||||
redirect(303, `/albums/${data.id}`);
|
||||
}
|
||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
@@ -20,6 +18,10 @@
|
||||
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();
|
||||
@@ -28,7 +30,7 @@
|
||||
const tracks = $derived(data.album.tracks);
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let saving = $state(false);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
|
||||
const queue = $derived<QueueItem[]>(
|
||||
tracks.map((track) => ({
|
||||
@@ -94,6 +96,7 @@
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
@@ -127,22 +130,6 @@
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="sm" variant="secondary" loading={saving}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Guardar en mi biblioteca
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
@@ -150,7 +137,7 @@
|
||||
{/if}
|
||||
|
||||
{#if data.album.description}
|
||||
<p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p>
|
||||
<p class="mt-6 text-sm text-fg-3">{data.album.description}</p>
|
||||
{/if}
|
||||
|
||||
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
|
||||
@@ -197,3 +184,11 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if albumMenu}
|
||||
<AlbumContextMenu
|
||||
menu={albumMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
addYouTubeToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
import {
|
||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||
@@ -55,7 +60,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
tracks,
|
||||
albums: albumsRes?.data?.items ?? [],
|
||||
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
|
||||
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
|
||||
ytResults: searchRes?.data ?? null,
|
||||
ytError: Boolean(searchRes?.error),
|
||||
youtubeFiller: query
|
||||
@@ -67,5 +71,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
import { HOME_ALBUMS_PAGE_SIZE, HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
@@ -16,14 +16,21 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
});
|
||||
const albumsPromise = api.GET('/albums/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: HOME_ALBUMS_PAGE_SIZE }
|
||||
}
|
||||
});
|
||||
const mixesPromise = api.GET('/mixes');
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
@@ -41,6 +48,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
user.sub
|
||||
),
|
||||
playlists: playlistItems,
|
||||
albums: albums.data?.items ?? [],
|
||||
albumsHasMore: Boolean(albums.data?.hasNextPage),
|
||||
mixes: mixes.data ?? [],
|
||||
recentlyPlayed: recentlyPlayed.data ?? []
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
} from '$lib/tracks';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import MixCard from '$lib/components/ui/MixCard.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
@@ -29,7 +31,7 @@
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import { appendUnique, shuffle } from '$lib/collections';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -107,12 +109,58 @@
|
||||
}
|
||||
}
|
||||
const playlists = $derived(data.playlists);
|
||||
const albums = $derived(data.albums);
|
||||
const mixes = $derived(data.mixes);
|
||||
|
||||
function playLatest(index: number) {
|
||||
player.playOrToggle(latest.map(queueItemForTarget), index);
|
||||
}
|
||||
|
||||
function playShuffledLocal() {
|
||||
if (localLatest.length === 0) return;
|
||||
player.playQueue(shuffle(localLatest).map(queueItemForTarget), 0);
|
||||
}
|
||||
|
||||
function playShuffledYoutube() {
|
||||
if (youtubeFillerItems.length === 0) return;
|
||||
const targets = youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }));
|
||||
player.playQueue(shuffle(targets).map(queueItemForTarget), 0);
|
||||
}
|
||||
|
||||
interface QuickMix {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
coverIds?: (string | number)[];
|
||||
coverSongs?: YouTubeSong[];
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const quickMixes = $derived<QuickMix[]>(
|
||||
(
|
||||
[
|
||||
localLatest.length > 0
|
||||
? {
|
||||
key: 'local',
|
||||
title: 'Mezcla Musify',
|
||||
subtitle: `${localLatest.length} ${localLatest.length === 1 ? 'canción' : 'canciones'}`,
|
||||
coverIds: localLatest.map((item) => targetId(item)),
|
||||
onClick: playShuffledLocal
|
||||
}
|
||||
: null,
|
||||
youtubeFillerItems.length > 0
|
||||
? {
|
||||
key: 'youtube',
|
||||
title: 'Mezcla YouTube Music',
|
||||
subtitle: `${youtubeFillerItems.length} ${youtubeFillerItems.length === 1 ? 'canción' : 'canciones'}`,
|
||||
coverSongs: youtubeFillerItems.slice(0, 4),
|
||||
onClick: playShuffledYoutube
|
||||
}
|
||||
: null
|
||||
] as (QuickMix | null)[]
|
||||
).filter((mix): mix is QuickMix => mix !== null)
|
||||
);
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
@@ -225,6 +273,46 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if quickMixes.length > 0}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Mezclas rápidas" />
|
||||
<MediaGrid>
|
||||
{#each quickMixes as mix, i (mix.key)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={mix.onClick}
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{i * 45}ms"
|
||||
>
|
||||
{#if mix.coverSongs}
|
||||
<div
|
||||
class="grid aspect-square w-full grid-cols-2 grid-rows-2 overflow-hidden rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
>
|
||||
{#each mix.coverSongs as song (song.videoId)}
|
||||
<Cover
|
||||
trackId={song.videoId}
|
||||
src={song.thumbnailUrl}
|
||||
hue={hueFor(song.videoId)}
|
||||
size="small"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<PlaylistArt
|
||||
trackIds={mix.coverIds ?? []}
|
||||
hue={hueFor(mix.key)}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
{/if}
|
||||
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{mix.subtitle}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if mixes.length > 0}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Hechas para ti" />
|
||||
@@ -254,6 +342,32 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Tus álbumes">
|
||||
{#snippet actions()}
|
||||
{#if data.albumsHasMore}
|
||||
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">
|
||||
Ver todos
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SectionHeading>
|
||||
<MediaGrid>
|
||||
{#each albums as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
title={album.title}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackCount={Number(album.trackCount)}
|
||||
trackIds={album.coverTrackIds}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="page-x pt-5 pb-10 sm:pt-6">
|
||||
<SectionHeading title="Novedades" />
|
||||
{#if latest.length > 0}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
|
||||
|
||||
const query = url.searchParams.get('query')?.trim() ?? '';
|
||||
if (!query) {
|
||||
error(400, 'Falta el término de búsqueda.');
|
||||
}
|
||||
|
||||
const continuation = url.searchParams.get('continuation') ?? undefined;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.GET('/youtube/albums', {
|
||||
params: { query: { query, continuation } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudieron buscar álbumes en YouTube Music.'));
|
||||
};
|
||||
@@ -28,6 +28,7 @@
|
||||
--radius-panel: 1rem;
|
||||
--radius-art: 0.75rem;
|
||||
--radius-art-lg: 1rem;
|
||||
--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);
|
||||
|
||||
Reference in New Issue
Block a user