feat(frontend): YouTube Music search, playback and playlist saving

This commit is contained in:
2026-07-16 01:06:20 +02:00
parent 24595a17d6
commit 560a943bab
8 changed files with 856 additions and 133 deletions
+87 -13
View File
@@ -2,12 +2,16 @@ import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube';
export interface PlayerTrack {
id: string | number;
title: string;
artist: string;
duration: number;
hue: number;
source: TrackSourceKind;
coverUrl?: string;
}
export interface Playlist {
@@ -20,11 +24,43 @@ export interface QueueItem {
id: string | number;
title: string;
artist?: string;
source?: TrackSourceKind;
coverUrl?: string;
}
export interface ApiTrackLike {
id: string | number;
title: string;
artist?: string;
source?: 'Local' | 'YouTube';
externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
}
export function isPendingYouTubeTrack(track: ApiTrackLike): boolean {
return track.source === 'YouTube' && track.audioStatus !== 'Completed';
}
export function queueIdForTrack(track: ApiTrackLike): string | number {
return isPendingYouTubeTrack(track) && track.externalId ? track.externalId : track.id;
}
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
return tracks.map((track) => {
const pendingYouTube = isPendingYouTubeTrack(track);
return {
id: queueIdForTrack(track),
title: track.title,
artist: track.artist,
source: pendingYouTube ? 'youtube' : 'local',
coverUrl: pendingYouTube ? `/api/tracks/${track.id}/cover?size=small` : undefined
};
});
}
const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0] };
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' };
function hueFor(id: string | number): number {
const text = String(id);
@@ -39,7 +75,9 @@ function toTrack(item: QueueItem): PlayerTrack {
title: item.title,
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id)
hue: hueFor(item.id),
source: item.source ?? 'local',
coverUrl: item.coverUrl
};
}
@@ -61,6 +99,7 @@ class PlayerState {
#audio: HTMLAudioElement | null = null;
#loadToken = 0;
#retriedId: string | number | null = null;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
@@ -97,6 +136,15 @@ class PlayerState {
});
audio.addEventListener('error', () => {
this.#stopProgressLoop();
if (
this.currentId !== null &&
this.current.source === 'youtube' &&
this.#retriedId !== this.currentId
) {
this.#retriedId = this.currentId;
this.#loadCurrent();
return;
}
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
@@ -143,7 +191,7 @@ class PlayerState {
);
}
async #applyAccent(id: string | number) {
async #applyAccent(id: string | number, coverUrl?: string) {
const key = String(id);
const cached = this.#accentCache.get(key);
if (cached) {
@@ -151,7 +199,7 @@ class PlayerState {
this.gradientColor = cached.gradient;
return;
}
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
const result = await extractAccent(coverUrl ?? `/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(key, result);
@@ -167,24 +215,23 @@ class PlayerState {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
const id = this.currentId;
this.#applyAccent(id);
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id, track?.coverUrl);
const token = ++this.#loadToken;
this.loading = true;
this.error = '';
this.progress = 0;
try {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string;
ticket: string;
};
const src =
track?.source === 'youtube'
? await this.#resolveYouTubeSrc(String(id))
: await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return;
const sep = manifestUrl.includes('?') ? '&' : '?';
audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl;
audio.src = src;
audio.volume = this.volume / 100;
await audio.play();
this.loading = false;
this.#retriedId = null;
this.#pushRecent(this.current);
} catch {
if (token !== this.#loadToken) return;
@@ -194,6 +241,33 @@ class PlayerState {
}
}
async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string;
ticket: string;
};
return this.#withTicket(manifestUrl, ticket);
}
async #resolveYouTubeSrc(videoId: string): Promise<string> {
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { mode, streamUrl, ticket } = (await res.json()) as {
mode: string;
streamUrl: string;
ticket: string;
};
return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl;
}
#withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
}
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids
.map((id) => this.tracks.find((t) => t.id === id))