feat(web-player): show YouTube Music albums in the explore search
The Albums chip now returns both your own albums and what YouTube Music has for the term. A YouTube card opens a read-only album page with its cover, tracklist and play button, and right-clicking a track still offers to add it to a playlist through the existing menu.
This commit is contained in:
Vendored
+140
@@ -454,6 +454,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/youtube/albums": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Search Albums On YouTube Music. */
|
||||
get: operations["SearchYouTubeAlbums"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/youtube/albums/{albumId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A YouTube Music Album With Its Tracklist. */
|
||||
get: operations["GetYouTubeAlbum"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/youtube/tracks/{videoId}/stream": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -696,6 +730,37 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
YouTubeAlbumDetail: {
|
||||
album: components["schemas"]["YouTubeAlbumResult"];
|
||||
description: string;
|
||||
/** Format: int32 */
|
||||
totalDurationSeconds: number | string;
|
||||
tracks: components["schemas"]["YouTubeAlbumTrack"][];
|
||||
};
|
||||
YouTubeAlbumResult: {
|
||||
albumId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
thumbnailUrl: string;
|
||||
/** Format: int32 */
|
||||
releaseYear: null | number | string;
|
||||
isSingle: boolean;
|
||||
isEp: boolean;
|
||||
artists: components["schemas"]["YouTubeArtistRef"][];
|
||||
};
|
||||
YouTubeAlbumSearchResult: {
|
||||
items: components["schemas"]["YouTubeAlbumResult"][];
|
||||
continuationToken: string;
|
||||
};
|
||||
YouTubeAlbumTrack: {
|
||||
videoId: string;
|
||||
title: string;
|
||||
/** Format: int32 */
|
||||
durationSeconds: number | string;
|
||||
/** Format: int32 */
|
||||
trackNumber: number | string;
|
||||
isExplicit: boolean;
|
||||
};
|
||||
YouTubeArtistRef: {
|
||||
id: null | string;
|
||||
name: string;
|
||||
@@ -1962,6 +2027,81 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SearchYouTubeAlbums: {
|
||||
parameters: {
|
||||
query: {
|
||||
query: string;
|
||||
continuation?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["YouTubeAlbumSearchResult"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetYouTubeAlbum: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
albumId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["YouTubeAlbumDetail"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetYouTubeTrackStream: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,35 +1,68 @@
|
||||
<script lang="ts">
|
||||
import PlaylistArt from './PlaylistArt.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
href?: string;
|
||||
subtitle?: string;
|
||||
releaseYear?: number;
|
||||
trackCount: number;
|
||||
trackIds: (string | number)[];
|
||||
trackCount?: number;
|
||||
trackIds?: (string | number)[];
|
||||
coverSrc?: string | null;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { id, title, releaseYear, trackCount, trackIds, index = 0 }: Props = $props();
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
href,
|
||||
subtitle,
|
||||
releaseYear,
|
||||
trackCount,
|
||||
trackIds = [],
|
||||
coverSrc,
|
||||
index = 0
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
[releaseYear, `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`]
|
||||
.filter((part) => part !== undefined && part !== null)
|
||||
[
|
||||
subtitle,
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
trackCount === undefined
|
||||
? undefined
|
||||
: `${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter((part) => !!part)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/albums/{id}"
|
||||
href={href ?? `/albums/${id}`}
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
>
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
hue={hueFor(id)}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
{#if coverSrc}
|
||||
<Cover
|
||||
trackId={id}
|
||||
src={coverSrc}
|
||||
hue={hueFor(id)}
|
||||
size="large"
|
||||
alt={title}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
{:else}
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
hue={hueFor(id)}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
{/if}
|
||||
<div class="mt-2.5 truncate text-fg">{title}</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
|
||||
{#if meta}
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">{meta}</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [albumRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/youtube/albums/{albumId}', { params: { path: { albumId: params.albumId } } }),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
]);
|
||||
|
||||
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum de YouTube Music.', 404);
|
||||
|
||||
return { album, playlists: playlistsRes.data?.items ?? [] };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.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 type { TrackTarget } from '$lib/tracks';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const album = $derived(data.album.album);
|
||||
const tracks = $derived(data.album.tracks);
|
||||
|
||||
let contextMenu = $state<ContextMenuState | 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} · Musify</title>
|
||||
<meta name="description" content="Álbum {album.title} de {album.artist} en YouTube Music." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<a
|
||||
href="/explore"
|
||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||
>
|
||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</span>
|
||||
Volver a explorar
|
||||
</a>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<Cover
|
||||
trackId={album.albumId}
|
||||
src={album.thumbnailUrl}
|
||||
hue={hueFor(album.albumId)}
|
||||
size="large"
|
||||
alt={album.title}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music
|
||||
</p>
|
||||
<h1
|
||||
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
>
|
||||
{album.title}
|
||||
</h1>
|
||||
<p class="mt-3 text-base text-fg-2">{album.artist}</p>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{[
|
||||
album.releaseYear ? String(album.releaseYear) : undefined,
|
||||
`${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`,
|
||||
fmtTime(Number(data.album.totalDurationSeconds))
|
||||
]
|
||||
.filter((part) => !!part)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if data.album.description}
|
||||
<p class="mt-6 max-w-3xl 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">
|
||||
{#snippet headers()}
|
||||
<span class="text-center">#</span>
|
||||
<span>Título</span>
|
||||
<span class="hidden text-center sm:block">Duración</span>
|
||||
{/snippet}
|
||||
{#each tracks as track, i (track.videoId)}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === track.videoId}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={track.videoId}
|
||||
title={track.title}
|
||||
artist={album.artist}
|
||||
coverSrc={album.thumbnailUrl}
|
||||
hue={hueFor(track.videoId)}
|
||||
explicit={track.isExplicit}
|
||||
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}
|
||||
@@ -35,11 +35,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, searchRes, playlistsRes, albumsRes] = await Promise.all([
|
||||
const youtubeAlbumsPromise =
|
||||
query.length >= SEARCH_MIN_LENGTH
|
||||
? api.GET('/youtube/albums', { params: { query: { query } } })
|
||||
: Promise.resolve(null);
|
||||
|
||||
const [tracksRes, searchRes, playlistsRes, albumsRes, youtubeAlbumsRes] = await Promise.all([
|
||||
tracksPromise,
|
||||
searchPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise
|
||||
albumsPromise,
|
||||
youtubeAlbumsPromise
|
||||
]);
|
||||
|
||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||
@@ -48,6 +54,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
query,
|
||||
tracks,
|
||||
albums: albumsRes?.data?.items ?? [],
|
||||
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
|
||||
ytResults: searchRes?.data ?? null,
|
||||
ytError: Boolean(searchRes?.error),
|
||||
youtubeFiller: query
|
||||
|
||||
@@ -101,12 +101,14 @@
|
||||
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== ''));
|
||||
|
||||
const albums = $derived(data.albums);
|
||||
const youtubeAlbums = $derived(data.youtubeAlbums);
|
||||
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
|
||||
|
||||
const nothingFound = $derived(
|
||||
!!data.query &&
|
||||
!data.ytError &&
|
||||
(showTracks ? items.length === 0 : true) &&
|
||||
(showAlbums ? albums.length === 0 : true) &&
|
||||
(showAlbums ? !hasAlbums : true) &&
|
||||
filters.length > 0
|
||||
);
|
||||
|
||||
@@ -230,7 +232,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAlbums && albums.length > 0}
|
||||
{#if showAlbums && hasAlbums}
|
||||
<div class="mt-8">
|
||||
<SectionHeading title="Álbumes" />
|
||||
<MediaGrid class="mt-4">
|
||||
@@ -240,16 +242,26 @@
|
||||
title={album.title}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackCount={Number(album.trackCount)}
|
||||
trackIds={[]}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
{#each youtubeAlbums as album, i (album.albumId)}
|
||||
<AlbumCard
|
||||
id={album.albumId}
|
||||
href="/albums/youtube/{album.albumId}"
|
||||
title={album.title}
|
||||
subtitle={album.artist}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
coverSrc={album.thumbnailUrl}
|
||||
index={albums.length + i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showTracks && items.length > 0}
|
||||
{#if showAlbums && albums.length > 0}
|
||||
{#if showAlbums && hasAlbums}
|
||||
<SectionHeading title="Canciones" class="mt-10" />
|
||||
{/if}
|
||||
<MediaGrid as="ul" class="mt-6">
|
||||
|
||||
Reference in New Issue
Block a user