Design Prot Merge #1
Vendored
+268
@@ -73,6 +73,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/playlists/{id}/cover": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get A PlayList Cover Image. */
|
||||||
|
get: operations["GetPlayListCover"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/playlists/{playlistId}": {
|
"/playlists/{playlistId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -126,6 +143,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/playlists/{playlistId}/youtube-tracks": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Add A YouTube Track To A PlayList, Downloading It In The Background If Needed. */
|
||||||
|
post: operations["AddYouTubeTrackToPlayList"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/tracks": {
|
"/tracks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -298,10 +332,52 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/youtube/search": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Search Songs On YouTube Music. */
|
||||||
|
get: operations["SearchYouTubeTracks"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/youtube/tracks/{videoId}/stream": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. */
|
||||||
|
get: operations["GetYouTubeTrackStream"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AddYouTubeTrackRequest: {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
durationSeconds: number | string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
};
|
||||||
CreatePlayListRequest: {
|
CreatePlayListRequest: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -396,6 +472,8 @@ export interface components {
|
|||||||
expiresInSeconds: number | string;
|
expiresInSeconds: number | string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
};
|
};
|
||||||
|
/** @enum {unknown} */
|
||||||
|
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
|
||||||
RequestPlayListPictureUploadRequest: {
|
RequestPlayListPictureUploadRequest: {
|
||||||
fileType: string;
|
fileType: string;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
@@ -416,6 +494,10 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
source: components["schemas"]["TrackSource"];
|
||||||
|
externalId: string;
|
||||||
|
audioStatus: components["schemas"]["ProcessingStatus"];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
duration: number | string;
|
duration: number | string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -425,6 +507,8 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
/** @enum {unknown} */
|
||||||
|
TrackSource: "Local" | "YouTube";
|
||||||
TrackStreamResponse: {
|
TrackStreamResponse: {
|
||||||
manifestUrl: string;
|
manifestUrl: string;
|
||||||
ticket: string;
|
ticket: string;
|
||||||
@@ -466,6 +550,30 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
YouTubeSearchResult: {
|
||||||
|
items: components["schemas"]["YouTubeSongResult"][];
|
||||||
|
continuationToken: string;
|
||||||
|
};
|
||||||
|
YouTubeSongResult: {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
durationSeconds: number | string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
};
|
||||||
|
/** @enum {unknown} */
|
||||||
|
YouTubeStreamMode: "Server" | "YouTube";
|
||||||
|
YouTubeStreamResponse: {
|
||||||
|
mode: components["schemas"]["YouTubeStreamMode"];
|
||||||
|
streamUrl: string;
|
||||||
|
ticket: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
expiresInSeconds: number | string;
|
||||||
|
/** Format: uuid */
|
||||||
|
trackId: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -632,6 +740,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
GetPlayListCover: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
size?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
UpdatePlayList: {
|
UpdatePlayList: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -839,6 +976,62 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
AddYouTubeTrackToPlayList: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
playlistId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AddYouTubeTrackRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TrackApplicationResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Conflict */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
GetTracks: {
|
GetTracks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1254,4 +1447,79 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SearchYouTubeTracks: {
|
||||||
|
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"]["YouTubeSearchResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
GetYouTubeTrackStream: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
videoId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["YouTubeStreamResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { browser } from '$app/environment';
|
|||||||
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
|
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
|
|
||||||
|
export type TrackSourceKind = 'local' | 'youtube';
|
||||||
|
|
||||||
export interface PlayerTrack {
|
export interface PlayerTrack {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
hue: number;
|
hue: number;
|
||||||
|
source: TrackSourceKind;
|
||||||
|
coverUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
@@ -20,11 +24,43 @@ export interface QueueItem {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist?: 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 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 {
|
function hueFor(id: string | number): number {
|
||||||
const text = String(id);
|
const text = String(id);
|
||||||
@@ -39,7 +75,9 @@ function toTrack(item: QueueItem): PlayerTrack {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist ?? '',
|
artist: item.artist ?? '',
|
||||||
duration: 0,
|
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;
|
#audio: HTMLAudioElement | null = null;
|
||||||
#loadToken = 0;
|
#loadToken = 0;
|
||||||
|
#retriedId: string | number | null = null;
|
||||||
#accentCache = new Map<string, Accent>();
|
#accentCache = new Map<string, Accent>();
|
||||||
#rafId: number | null = null;
|
#rafId: number | null = null;
|
||||||
|
|
||||||
@@ -97,6 +136,15 @@ class PlayerState {
|
|||||||
});
|
});
|
||||||
audio.addEventListener('error', () => {
|
audio.addEventListener('error', () => {
|
||||||
this.#stopProgressLoop();
|
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.loading = false;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.error = 'No se pudo reproducir la pista.';
|
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 key = String(id);
|
||||||
const cached = this.#accentCache.get(key);
|
const cached = this.#accentCache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -151,7 +199,7 @@ class PlayerState {
|
|||||||
this.gradientColor = cached.gradient;
|
this.gradientColor = cached.gradient;
|
||||||
return;
|
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 (this.currentId !== id) return;
|
||||||
if (result) {
|
if (result) {
|
||||||
this.#accentCache.set(key, result);
|
this.#accentCache.set(key, result);
|
||||||
@@ -167,24 +215,23 @@ class PlayerState {
|
|||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
if (!audio || this.currentId === null) return;
|
if (!audio || this.currentId === null) return;
|
||||||
const id = this.currentId;
|
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;
|
const token = ++this.#loadToken;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.progress = 0;
|
this.progress = 0;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tracks/${id}/stream`);
|
const src =
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
track?.source === 'youtube'
|
||||||
const { manifestUrl, ticket } = (await res.json()) as {
|
? await this.#resolveYouTubeSrc(String(id))
|
||||||
manifestUrl: string;
|
: await this.#resolveLocalSrc(String(id));
|
||||||
ticket: string;
|
|
||||||
};
|
|
||||||
if (token !== this.#loadToken) return;
|
if (token !== this.#loadToken) return;
|
||||||
const sep = manifestUrl.includes('?') ? '&' : '?';
|
audio.src = src;
|
||||||
audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl;
|
|
||||||
audio.volume = this.volume / 100;
|
audio.volume = this.volume / 100;
|
||||||
await audio.play();
|
await audio.play();
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.#retriedId = null;
|
||||||
this.#pushRecent(this.current);
|
this.#pushRecent(this.current);
|
||||||
} catch {
|
} catch {
|
||||||
if (token !== this.#loadToken) return;
|
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[] {
|
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
|
||||||
return ids
|
return ids
|
||||||
.map((id) => this.tracks.find((t) => t.id === id))
|
.map((id) => this.tracks.find((t) => t.id === id))
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import { createApiClient } from '$lib/server/api';
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||||
|
const src = url.searchParams.get('src') === 'yt' ? 'yt' : ('local' as const);
|
||||||
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
|
||||||
|
if (src === 'yt') {
|
||||||
|
const emptyResults = { items: [], continuationToken: '' };
|
||||||
|
|
||||||
|
const playlistsPromise = locals.user
|
||||||
|
? api.GET('/playlists/users/{userId}', {
|
||||||
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||||
|
})
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const searchPromise =
|
||||||
|
query && locals.accessToken
|
||||||
|
? api.GET('/youtube/search', { params: { query: { query } } })
|
||||||
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
const [playlistsRes, searchRes] = await Promise.all([playlistsPromise, searchPromise]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
src: 'yt' as const,
|
||||||
|
query,
|
||||||
|
ytResults: searchRes?.data ?? emptyResults,
|
||||||
|
ytError: Boolean(searchRes?.error),
|
||||||
|
playlists: playlistsRes?.data?.items ?? [],
|
||||||
|
needsAuth: !locals.user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error: err } = await api.GET('/tracks', {
|
const { data, error: err } = await api.GET('/tracks', {
|
||||||
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
|
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
|
||||||
});
|
});
|
||||||
@@ -17,5 +45,38 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
error(502, 'No se pudieron cargar las canciones.');
|
error(502, 'No se pudieron cargar las canciones.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { tracks: data, query };
|
return { src: 'local' as const, query, tracks: data };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addYouTubeToPlaylist: async ({ request, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const playlistId = String(form.get('playlistId') ?? '');
|
||||||
|
const videoId = String(form.get('videoId') ?? '');
|
||||||
|
const title = String(form.get('title') ?? '');
|
||||||
|
const artist = String(form.get('artist') ?? '');
|
||||||
|
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
|
||||||
|
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
|
||||||
|
|
||||||
|
if (!playlistId || !videoId || !title) {
|
||||||
|
return fail(400, { message: 'Faltan datos de la canción.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
|
||||||
|
params: { path: { playlistId } },
|
||||||
|
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
if (response?.status === 409) {
|
||||||
|
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
|
||||||
|
}
|
||||||
|
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { added: true, videoId, playlistId };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,115 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
|
import Check from '@lucide/svelte/icons/check';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { enhance } from '$app/forms';
|
||||||
|
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import { HUES } from '$lib/theme/color';
|
import { HUES } from '$lib/theme/color';
|
||||||
|
|
||||||
let { data } = $props();
|
interface YouTubeSong {
|
||||||
|
videoId: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
durationSeconds: number;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
const tracks = $derived(data.tracks);
|
let { data, form } = $props();
|
||||||
const currentPage = $derived(Number(tracks.pageNumber));
|
|
||||||
const total = $derived(Number(tracks.totalItemCount));
|
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
let ytItems = $state<YouTubeSong[]>([]);
|
||||||
|
let ytContinuation = $state('');
|
||||||
|
let ytLoadingMore = $state(false);
|
||||||
|
let addedVideoIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (data.src === 'yt') {
|
||||||
|
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
|
||||||
|
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||||
|
addedVideoIds = new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function onSearchInput(event: Event) {
|
function onSearchInput(event: Event) {
|
||||||
const value = (event.currentTarget as HTMLInputElement).value;
|
const value = (event.currentTarget as HTMLInputElement).value;
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (value.trim()) params.set('q', value.trim());
|
if (value.trim()) params.set('q', value.trim());
|
||||||
|
if (data.src === 'yt') params.set('src', 'yt');
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
goto(qs ? `/explore?${qs}` : '/explore', { keepFocus: true, replaceState: true, noScroll: true });
|
goto(qs ? `/explore?${qs}` : '/explore', { keepFocus: true, replaceState: true, noScroll: true });
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tabHref(src: 'local' | 'yt') {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (data.query) params.set('q', data.query);
|
||||||
|
if (src === 'yt') params.set('src', 'yt');
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `/explore?${qs}` : '/explore';
|
||||||
|
}
|
||||||
|
|
||||||
function hueFor(id: string) {
|
function hueFor(id: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
return HUES[hash % HUES.length];
|
return HUES[hash % HUES.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function formatDuration(seconds: number) {
|
||||||
const track = tracks.items[index];
|
const m = Math.floor(seconds / 60);
|
||||||
if (player.current?.id === track.id) player.toggle();
|
const s = Math.floor(seconds % 60);
|
||||||
else player.playQueue(tracks.items, index);
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlayLocal(index: number) {
|
||||||
|
if (data.src !== 'local') return;
|
||||||
|
const track = data.tracks.items[index];
|
||||||
|
if (player.current?.id === queueIdForTrack(track)) player.toggle();
|
||||||
|
else player.playQueue(toQueueItems(data.tracks.items), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlayYouTube(index: number) {
|
||||||
|
const song = ytItems[index];
|
||||||
|
if (player.current?.id === song.videoId) {
|
||||||
|
player.toggle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.playQueue(
|
||||||
|
ytItems.map((s) => ({
|
||||||
|
id: s.videoId,
|
||||||
|
title: s.title,
|
||||||
|
artist: s.artist,
|
||||||
|
source: 'youtube' as const,
|
||||||
|
coverUrl: s.thumbnailUrl
|
||||||
|
})),
|
||||||
|
index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreYouTube() {
|
||||||
|
if (!ytContinuation || ytLoadingMore) return;
|
||||||
|
ytLoadingMore = true;
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ query: data.query, continuation: ytContinuation });
|
||||||
|
const res = await fetch(`/api/youtube/search?${params}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||||
|
ytItems = [...ytItems, ...next.items];
|
||||||
|
ytContinuation = next.continuationToken;
|
||||||
|
} catch {
|
||||||
|
ytContinuation = '';
|
||||||
|
} finally {
|
||||||
|
ytLoadingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHref(page: number) {
|
function pageHref(page: number) {
|
||||||
@@ -50,14 +123,35 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Explorar · Musify</title>
|
<title>Explorar · Musify</title>
|
||||||
<meta name="description" content="Explora y busca canciones en Musify." />
|
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<section class="px-8 pt-12 pb-8">
|
<section class="px-8 pt-12 pb-8">
|
||||||
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
||||||
<p class="mt-2 text-neutral-400">Descubre canciones o busca por nombre.</p>
|
<p class="mt-2 text-neutral-400">Descubre canciones, busca por nombre o encuentra música en YouTube Music.</p>
|
||||||
|
|
||||||
<div class="relative mt-8">
|
<div class="mt-8 flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={tabHref('local')}
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
class="rounded-full px-4 py-1.5 text-sm transition {data.src === 'local'
|
||||||
|
? 'bg-emerald-500 font-semibold text-neutral-950'
|
||||||
|
: 'border border-white/15 text-neutral-300 hover:bg-white/5'}"
|
||||||
|
>
|
||||||
|
Mi música
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={tabHref('yt')}
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
class="rounded-full px-4 py-1.5 text-sm transition {data.src === 'yt'
|
||||||
|
? 'bg-emerald-500 font-semibold text-neutral-950'
|
||||||
|
: 'border border-white/15 text-neutral-300 hover:bg-white/5'}"
|
||||||
|
>
|
||||||
|
YouTube Music
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative mt-6">
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500">
|
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500">
|
||||||
<Search class="h-4 w-4" />
|
<Search class="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
@@ -65,92 +159,235 @@
|
|||||||
type="search"
|
type="search"
|
||||||
value={data.query}
|
value={data.query}
|
||||||
oninput={onSearchInput}
|
oninput={onSearchInput}
|
||||||
placeholder="Buscar por nombre..."
|
placeholder={data.src === 'yt' ? 'Buscar en YouTube Music...' : 'Buscar por nombre...'}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
|
class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-neutral-500">
|
{#if form?.message}
|
||||||
{#if data.query}
|
<p class="mt-4 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3 text-sm text-neutral-300">
|
||||||
{total}
|
{form.message}
|
||||||
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
</p>
|
||||||
{:else}
|
{/if}
|
||||||
{total}
|
|
||||||
{total === 1 ? 'canción' : 'canciones'}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if tracks.items.length === 0}
|
{#if data.src === 'yt'}
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
{#if data.needsAuth}
|
||||||
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
<p class="mt-4 text-neutral-300">
|
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
{#if data.query}
|
<p class="mt-4 text-neutral-300">Inicia sesión para buscar en YouTube Music.</p>
|
||||||
No hay canciones que coincidan con «{data.query}».
|
</div>
|
||||||
{:else}
|
{:else if !data.query}
|
||||||
Todavía no hay canciones. ¡Sé el primero en subir una!
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
{/if}
|
<Search class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
</p>
|
<p class="mt-4 text-neutral-300">Escribe algo para buscar canciones en YouTube Music.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if data.ytError}
|
||||||
<ul class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
{#each tracks.items as track, i (track.id)}
|
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
<li class="group animate-enter" style="animation-delay:{i * 40}ms">
|
<p class="mt-4 text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p>
|
||||||
<button type="button" onclick={() => togglePlay(i)} class="block w-full text-left">
|
</div>
|
||||||
<Cover
|
{:else if ytItems.length === 0}
|
||||||
trackId={track.id}
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
hue={hueFor(track.id)}
|
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
size="large"
|
<p class="mt-4 text-neutral-300">No hay resultados para «{data.query}».</p>
|
||||||
alt={track.title}
|
</div>
|
||||||
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
{:else}
|
||||||
|
<ul class="mt-8 space-y-2">
|
||||||
|
{#each ytItems as song, i (song.videoId)}
|
||||||
|
<li
|
||||||
|
class="group flex items-center gap-4 rounded-2xl border border-white/5 bg-neutral-900/40 p-3 transition hover:border-emerald-500/30 hover:bg-neutral-900/60"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => togglePlayYouTube(i)}
|
||||||
|
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg"
|
||||||
>
|
>
|
||||||
|
<img src={song.thumbnailUrl} alt={song.title} class="h-full w-full object-cover" loading="lazy" />
|
||||||
<span
|
<span
|
||||||
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
class="absolute inset-0 grid place-items-center bg-neutral-950/60 opacity-0 transition group-hover:opacity-100"
|
||||||
class:!opacity-100={player.current?.id === track.id}
|
class:!opacity-100={player.current?.id === song.videoId}
|
||||||
class:!translate-y-0={player.current?.id === track.id}
|
|
||||||
>
|
>
|
||||||
{#if player.current?.id === track.id && player.isPlaying}
|
{#if player.current?.id === song.videoId && player.isPlaying}
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 text-emerald-400">
|
||||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else}
|
{:else}
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 text-white">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</Cover>
|
</button>
|
||||||
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
|
|
||||||
{track.title}
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 truncate text-xs text-neutral-500">
|
|
||||||
{dateFormatter.format(new Date(track.createdAt))}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
<button type="button" onclick={() => togglePlayYouTube(i)} class="min-w-0 flex-1 text-left">
|
||||||
<nav class="mt-10 flex items-center justify-center gap-4">
|
<div class="truncate text-sm font-semibold text-[var(--mf-text)]" title={song.title}>
|
||||||
{#if tracks.hasPreviousPage}
|
{song.title}
|
||||||
<a
|
</div>
|
||||||
href={pageHref(currentPage - 1)}
|
<div class="mt-0.5 truncate text-xs text-neutral-400">
|
||||||
class="rounded-lg border border-white/20 px-5 py-2 text-sm font-medium text-white backdrop-blur-md transition hover:bg-white/5"
|
{song.artist}{song.album ? ` · ${song.album}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="shrink-0 text-xs tabular-nums text-neutral-500">
|
||||||
|
{formatDuration(song.durationSeconds)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if data.playlists.length > 0}
|
||||||
|
<details class="relative shrink-0">
|
||||||
|
<summary
|
||||||
|
class="grid h-9 w-9 cursor-pointer list-none place-items-center rounded-full border border-white/15 text-neutral-300 transition hover:bg-white/5 [&::-webkit-details-marker]:hidden"
|
||||||
|
title="Añadir a playlist"
|
||||||
|
>
|
||||||
|
{#if addedVideoIds.has(song.videoId)}
|
||||||
|
<Check class="h-4 w-4 text-emerald-400" />
|
||||||
|
{:else}
|
||||||
|
<ListPlus class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 z-20 mt-2 w-56 rounded-2xl border border-white/10 bg-neutral-900 p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.8)]"
|
||||||
|
>
|
||||||
|
<p class="px-3 py-1.5 text-xs font-semibold text-neutral-500">Añadir a playlist</p>
|
||||||
|
{#each data.playlists as playlist (playlist.id)}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addYouTubeToPlaylist"
|
||||||
|
use:enhance={() =>
|
||||||
|
({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
addedVideoIds = new Set([...addedVideoIds, song.videoId]);
|
||||||
|
}
|
||||||
|
return update({ reset: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||||
|
<input type="hidden" name="videoId" value={song.videoId} />
|
||||||
|
<input type="hidden" name="title" value={song.title} />
|
||||||
|
<input type="hidden" name="artist" value={song.artist} />
|
||||||
|
<input type="hidden" name="durationSeconds" value={song.durationSeconds} />
|
||||||
|
<input type="hidden" name="thumbnailUrl" value={song.thumbnailUrl} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full truncate rounded-lg px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{playlist.name}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if ytContinuation}
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={loadMoreYouTube}
|
||||||
|
disabled={ytLoadingMore}
|
||||||
|
class="flex items-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Anterior
|
{#if ytLoadingMore}
|
||||||
</a>
|
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-sm text-neutral-500">Página {currentPage}</span>
|
Cargar más
|
||||||
{#if tracks.hasNextPage}
|
</button>
|
||||||
<a
|
</div>
|
||||||
href={pageHref(currentPage + 1)}
|
{/if}
|
||||||
class="rounded-lg border border-white/20 px-5 py-2 text-sm font-medium text-white backdrop-blur-md transition hover:bg-white/5"
|
{/if}
|
||||||
>
|
{:else}
|
||||||
Siguiente
|
{@const tracks = data.tracks}
|
||||||
</a>
|
{@const currentPage = Number(tracks.pageNumber)}
|
||||||
{/if}
|
{@const total = Number(tracks.totalItemCount)}
|
||||||
</nav>
|
|
||||||
|
<p class="mt-6 text-sm text-neutral-500">
|
||||||
|
{#if data.query}
|
||||||
|
{total}
|
||||||
|
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
||||||
|
{:else}
|
||||||
|
{total}
|
||||||
|
{total === 1 ? 'canción' : 'canciones'}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if tracks.items.length === 0}
|
||||||
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
|
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
|
<p class="mt-4 text-neutral-300">
|
||||||
|
{#if data.query}
|
||||||
|
No hay canciones que coincidan con «{data.query}».
|
||||||
|
{:else}
|
||||||
|
Todavía no hay canciones. ¡Sé el primero en subir una!
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
|
{#each tracks.items as track, i (track.id)}
|
||||||
|
<li class="group animate-enter" style="animation-delay:{i * 40}ms">
|
||||||
|
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
|
||||||
|
<Cover
|
||||||
|
trackId={track.id}
|
||||||
|
hue={hueFor(track.id)}
|
||||||
|
size="large"
|
||||||
|
alt={track.title}
|
||||||
|
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
||||||
|
class:!opacity-100={player.current?.id === track.id}
|
||||||
|
class:!translate-y-0={player.current?.id === track.id}
|
||||||
|
>
|
||||||
|
{#if player.current?.id === track.id && player.isPlaying}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Cover>
|
||||||
|
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
|
||||||
|
{track.title}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate text-xs text-neutral-500">
|
||||||
|
{#if track.artist}
|
||||||
|
{track.artist}
|
||||||
|
{:else}
|
||||||
|
{dateFormatter.format(new Date(track.createdAt))}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
||||||
|
<nav class="mt-10 flex items-center justify-center gap-4">
|
||||||
|
{#if tracks.hasPreviousPage}
|
||||||
|
<a
|
||||||
|
href={pageHref(currentPage - 1)}
|
||||||
|
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||||
|
>
|
||||||
|
Anterior
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm text-neutral-500">Página {currentPage}</span>
|
||||||
|
{#if tracks.hasNextPage}
|
||||||
|
<a
|
||||||
|
href={pageHref(currentPage + 1)}
|
||||||
|
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,14 +3,21 @@
|
|||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
|
import SquarePause from '@lucide/svelte/icons/square-pause';
|
||||||
import Pencil from '@lucide/svelte/icons/pencil';
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import ImageIcon from '@lucide/svelte/icons/image';
|
import ImageIcon from '@lucide/svelte/icons/image';
|
||||||
import Headphones from '@lucide/svelte/icons/headphones';
|
import Headphones from '@lucide/svelte/icons/headphones';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import {
|
||||||
|
player,
|
||||||
|
isPendingYouTubeTrack,
|
||||||
|
queueIdForTrack,
|
||||||
|
toQueueItems
|
||||||
|
} from '$lib/player/player.svelte';
|
||||||
import { HUES, fmtTime } from '$lib/theme/color';
|
import { HUES, fmtTime } from '$lib/theme/color';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
@@ -47,24 +54,24 @@
|
|||||||
return HUES[hash % HUES.length];
|
return HUES[hash % HUES.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
if (isCurrentQueue) player.toggle();
|
if (isCurrentQueue) player.toggle();
|
||||||
else player.playQueue(tracks, 0);
|
else player.playQueue(toQueueItems(tracks), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
const track = tracks[index];
|
const track = tracks[index];
|
||||||
if (player.current.id === track.id) player.toggle();
|
if (player.current.id === queueIdForTrack(track)) player.toggle();
|
||||||
else player.playQueue(tracks, index);
|
else player.playQueue(toQueueItems(tracks), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playFromLibrary(index: number) {
|
function playFromLibrary(index: number) {
|
||||||
const track = library[index];
|
const track = library[index];
|
||||||
if (player.current.id === track.id) player.toggle();
|
if (player.current.id === queueIdForTrack(track)) player.toggle();
|
||||||
else player.playQueue(library, index);
|
else player.playQueue(toQueueItems(library), index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,62 +114,62 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-7 flex items-center gap-3">
|
<div class="mt-7 flex items-center gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={playAll}
|
onclick={playAll}
|
||||||
disabled={tracks.length === 0}
|
disabled={tracks.length === 0}
|
||||||
class="inline-flex h-12 items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-7 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_10px_28px_-8px] active:scale-95 disabled:opacity-40"
|
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--mf-accent)] px-4 py-2 text-neutral-900 transition hover:brightness-110 active:scale-95 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{#if isCurrentQueue && player.playing}
|
{#if isCurrentQueue && player.playing}
|
||||||
<Pause class="h-4 w-4" fill="currentColor" />
|
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||||
Pausar
|
Pausar
|
||||||
{:else}
|
{:else}
|
||||||
<Play class="h-4 w-4" fill="currentColor" />
|
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||||
Reproducir
|
Reproducir
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={openEdit}
|
onclick={openEdit}
|
||||||
class="inline-flex h-12 items-center gap-2 rounded-lg border border-white/15 px-5 text-sm font-semibold text-white transition hover:bg-white/5"
|
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
|
||||||
>
|
>
|
||||||
<Pencil class="h-4 w-4" />
|
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||||
Editar
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (confirmingDelete = true)}
|
onclick={() => (confirmingDelete = true)}
|
||||||
aria-label="Eliminar playlist"
|
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
|
||||||
class="grid h-12 w-12 place-items-center rounded-lg border border-white/15 text-neutral-300 transition hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-400"
|
|
||||||
>
|
>
|
||||||
<Trash2 class="h-4 w-4" />
|
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
|
class="text-center grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
|
||||||
>
|
>
|
||||||
<span class="text-center">#</span>
|
<span>#</span>
|
||||||
<span>Título</span>
|
<span class="text-left">Título</span>
|
||||||
<span>Añadida</span>
|
<span>Añadida</span>
|
||||||
<span class="flex justify-end"><Clock class="h-3.5 w-3.5" /></span>
|
<span>Duración</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-col">
|
<div class="mt-1 flex flex-col">
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
<div
|
<div
|
||||||
class="group grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60"
|
class="text-center group grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => playFrom(i)}
|
onclick={() => playFrom(i)}
|
||||||
aria-label={player.current.id === track.id && player.playing ? 'Pausar' : 'Reproducir'}
|
aria-label={player.current.id === queueIdForTrack(track) && player.playing ? 'Pausar' : 'Reproducir'}
|
||||||
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
|
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
|
||||||
>
|
>
|
||||||
{#if player.current.id === track.id}
|
{#if player.current.id === queueIdForTrack(track)}
|
||||||
<NowPlaying paused={!player.playing} />
|
<NowPlaying paused={!player.playing} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="group-hover:hidden">{i + 1}</span>
|
<span class="group-hover:hidden">{i + 1}</span>
|
||||||
@@ -177,12 +184,30 @@
|
|||||||
alt={track.title}
|
alt={track.title}
|
||||||
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
|
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</div>
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<span class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</span>
|
||||||
|
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
|
||||||
|
<span class="shrink-0 rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-semibold text-red-300">
|
||||||
|
Error
|
||||||
|
</span>
|
||||||
|
{:else if isPendingYouTubeTrack(track)}
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-semibold text-emerald-400"
|
||||||
|
>
|
||||||
|
Descargando
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if track.artist}
|
||||||
|
<div class="truncate text-left text-xs text-neutral-500">{track.artist}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="truncate text-[12.5px] text-neutral-500">
|
<div class="truncate text-[12.5px] text-neutral-500">
|
||||||
{dateFormatter.format(new Date(track.createdAt))}
|
{dateFormatter.format(new Date(track.createdAt))}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-right text-[12.5px] tabular-nums text-neutral-500">
|
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
|
||||||
{fmtTime(Number(track.duration))}
|
{fmtTime(Number(track.duration))}
|
||||||
</span>
|
</span>
|
||||||
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
|
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
|
||||||
@@ -190,7 +215,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
aria-label="Quitar de la playlist"
|
aria-label="Quitar de la playlist"
|
||||||
class="grid h-7 w-7 place-items-center rounded-full text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
|
class="grid h-7 w-7 place-items-center rounded-lg text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
|
||||||
>
|
>
|
||||||
<X class="h-3.5 w-3.5" />
|
<X class="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -204,10 +229,18 @@
|
|||||||
{#if library.length > 0}
|
{#if library.length > 0}
|
||||||
<div class="mt-12">
|
<div class="mt-12">
|
||||||
<h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2>
|
<h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2>
|
||||||
<div class="mt-4 flex flex-col">
|
<div
|
||||||
|
class="mt-4 grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
|
||||||
|
>
|
||||||
|
<span>Título</span>
|
||||||
|
<span class="text-center">Escuchas</span>
|
||||||
|
<span class="text-center">Duración</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex flex-col">
|
||||||
{#each library as track, i (track.id)}
|
{#each library as track, i (track.id)}
|
||||||
<div
|
<div
|
||||||
class="group grid grid-cols-[1fr_140px_110px] items-center gap-4 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
|
class="group grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -227,10 +260,13 @@
|
|||||||
</Cover>
|
</Cover>
|
||||||
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
|
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center justify-start gap-1.5 text-[12.5px] tabular-nums text-neutral-500">
|
<div class="flex items-center justify-center gap-1.5 text-[12.5px] tabular-nums text-neutral-500">
|
||||||
<Headphones class="h-3.5 w-3.5" />
|
<Headphones class="h-3.5 w-3.5" />
|
||||||
{Number(track.listensCount)}
|
{Number(track.listensCount)}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
|
||||||
|
{fmtTime(Number(track.duration))}
|
||||||
|
</span>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/addTrack"
|
action="?/addTrack"
|
||||||
@@ -240,10 +276,10 @@
|
|||||||
<input type="hidden" name="trackId" value={track.id} />
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-white/15 px-4 py-2 text-xs text-white transition hover:border-[var(--mf-accent)]/50 hover:text-[var(--mf-accent)]"
|
class="inline-flex items-center gap-1.5 rounded-lg border-2 border-white/20 px-2 py-2 text-xs text-white/80 transition hover:border-[var(--mf-accent)]/50 hover:text-[var(--mf-accent)]"
|
||||||
>
|
>
|
||||||
<Plus class="h-3.5 w-3.5" />
|
|
||||||
Añadir
|
<Plus class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +308,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={() => (editing = false)}
|
onclick={() => (editing = false)}
|
||||||
aria-label="Cerrar"
|
aria-label="Cerrar"
|
||||||
class="grid h-8 w-8 place-items-center rounded-full text-neutral-400 transition hover:bg-white/5 hover:text-white"
|
class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white"
|
||||||
>
|
>
|
||||||
<X class="h-4 w-4" />
|
<X class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import { HUES } from '$lib/theme/color';
|
import { HUES } from '$lib/theme/color';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
|
|
||||||
function playLatest(index: number) {
|
function playLatest(index: number) {
|
||||||
const track = latest[index];
|
const track = latest[index];
|
||||||
if (player.current.id === track.id) player.toggle();
|
if (player.current.id === queueIdForTrack(track)) player.toggle();
|
||||||
else player.playQueue(latest, index);
|
else player.playQueue(toQueueItems(latest), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playRecent(index: number) {
|
function playRecent(index: number) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) {
|
||||||
|
error(401, '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: locals.accessToken });
|
||||||
|
const { data, error: err, response } = await api.GET('/youtube/search', {
|
||||||
|
params: { query: { query, continuation } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err || !data) {
|
||||||
|
error(response?.status ?? 502, 'No se pudo buscar en YouTube Music.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||||
|
if (!locals.accessToken) {
|
||||||
|
error(401, 'Inicia sesión para reproducir.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = createApiClient({ fetch, accessToken: locals.accessToken });
|
||||||
|
const { data, error: err, response } = await api.GET('/youtube/tracks/{videoId}/stream', {
|
||||||
|
params: { path: { videoId: params.videoId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err || !data) {
|
||||||
|
error(response?.status ?? 502, 'No se pudo obtener el stream de YouTube.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(data);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user