Remove YouTube integration from web-player; unify Home track rows
Drops every YouTube-backed route, proxy and type (external albums/tracks, thumbnail proxy, youtube.ts) and simplifies player/track/mix types to the local-only catalog now that the backend contract dropped Source/ExternalId. Genre tiles on Explore and "Populares esta semana" on Home stay as deliberate client-only placeholders per product decision, just without the YouTube backing. Also finishes the design-system unification pass: Home's three hand-rolled track rows (Continuar escuchando, Playlist destacada, Populares esta semana) now reuse the shared TrackTitleCell component instead of duplicating cover+title+artist markup, and the orphaned Rail.svelte (no importers) is removed.
This commit is contained in:
@@ -26,17 +26,6 @@ API_BASE_URL=http://localhost:5111
|
|||||||
# Dev already allows a plain-HTTP issuer. Set this only to allow it in a production build (never with a public issuer).
|
# Dev already allows a plain-HTTP issuer. Set this only to allow it in a production build (never with a public issuer).
|
||||||
# AUTH_ALLOW_INSECURE_HTTP=true
|
# AUTH_ALLOW_INSECURE_HTTP=true
|
||||||
|
|
||||||
# Optional tuning for the YouTube artwork proxy (/api/youtube/thumbnail),
|
|
||||||
# which caches the covers in memory so the browser never hits Google directly.
|
|
||||||
# THUMBNAIL_CACHE_MAX_AGE_SECONDS=2592000
|
|
||||||
# THUMBNAIL_FAILURE_CACHE_SECONDS=300
|
|
||||||
# THUMBNAIL_CACHE_BYTES=134217728
|
|
||||||
# THUMBNAIL_MAX_CONCURRENT_FETCHES=4
|
|
||||||
# THUMBNAIL_FETCH_TIMEOUT_MS=8000
|
|
||||||
# THUMBNAIL_COOLDOWN_SECONDS=30
|
|
||||||
# THUMBNAIL_MAX_COOLDOWN_SECONDS=300
|
|
||||||
# THUMBNAIL_MAX_SIZE=720
|
|
||||||
|
|
||||||
# Where to send presigned S3 PUTs when the public S3 host is not reachable from
|
# Where to send presigned S3 PUTs when the public S3 host is not reachable from
|
||||||
# the server (in Docker: http://seaweedfs:8333). The signed Host header is kept
|
# the server (in Docker: http://seaweedfs:8333). The signed Host header is kept
|
||||||
# either way. Defaults to the host in the presigned URL.
|
# either way. Defaults to the host in the presigned URL.
|
||||||
|
|||||||
@@ -1,30 +1,8 @@
|
|||||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||||
|
|
||||||
interface YouTubeAlbumTracksResponse {
|
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
||||||
album: { artist: string; thumbnailUrl: string };
|
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||||
tracks: { videoId: string; title: string; isExplicit: boolean }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchAlbumQueueItems(
|
|
||||||
kind: 'local' | 'youtube',
|
|
||||||
albumId: string
|
|
||||||
): Promise<QueueItem[]> {
|
|
||||||
if (kind === 'local') {
|
|
||||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
|
||||||
return toQueueItems(data.items ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`/api/albums/external/${kind}/${albumId}/tracks`);
|
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const detail = (await res.json()) as YouTubeAlbumTracksResponse;
|
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||||
return detail.tracks.map((track) => ({
|
return toQueueItems(data.items ?? []);
|
||||||
id: track.videoId,
|
|
||||||
title: track.title,
|
|
||||||
artist: detail.album.artist,
|
|
||||||
source: 'youtube' as const,
|
|
||||||
coverUrl: detail.album.thumbnailUrl,
|
|
||||||
explicit: track.isExplicit
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+11
-261
@@ -11,7 +11,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** Get Paginated Albums, Combined With Live YouTube Music Results When A Title Filter Is Given. */
|
/** Get Paginated Albums, Optionally Filtered By Title. */
|
||||||
get: operations["GetAlbums"];
|
get: operations["GetAlbums"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create A New Album. */
|
/** Create A New Album. */
|
||||||
@@ -39,23 +39,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/albums/external/{source}/{externalId}": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/** Get An Album From An External Source (Currently Only YouTube Music) With Its Tracklist. */
|
|
||||||
get: operations["GetExternalAlbum"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/albums/users/{userId}": {
|
"/albums/users/{userId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -220,7 +203,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
/** Like Or Unlike A Track By Id. */
|
||||||
post: operations["ToggleTrackLike"];
|
post: operations["ToggleTrackLike"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -324,7 +307,7 @@ export interface paths {
|
|||||||
/** Get Paginated Tracks Of A PlayList. */
|
/** Get Paginated Tracks Of A PlayList. */
|
||||||
get: operations["GetPlayListTracks"];
|
get: operations["GetPlayListTracks"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Add A Track To A PlayList, By Id Or By External Source, Provisioning It In The Background If Needed. */
|
/** Add One Of Your Tracks To A PlayList. */
|
||||||
post: operations["AddPlayListTrack"];
|
post: operations["AddPlayListTrack"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
@@ -408,7 +391,7 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
/** Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given. */
|
/** Get Paginated Tracks, Optionally Filtered By Name. */
|
||||||
get: operations["GetTracks"];
|
get: operations["GetTracks"];
|
||||||
put?: never;
|
put?: never;
|
||||||
/** Create A New Track. */
|
/** Create A New Track. */
|
||||||
@@ -470,23 +453,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/tracks/external/{source}/{externalId}/stream": {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path?: never;
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
/** Resolve Playback For A Track From An External Source (Currently Only YouTube Music): Server Stream If Downloaded, Direct Stream From The Source Otherwise. Works Anonymously When The Playback Configuration Allows It. */
|
|
||||||
get: operations["GetExternalTrackStream"];
|
|
||||||
put?: never;
|
|
||||||
post?: never;
|
|
||||||
delete?: never;
|
|
||||||
options?: never;
|
|
||||||
head?: never;
|
|
||||||
patch?: never;
|
|
||||||
trace?: never;
|
|
||||||
};
|
|
||||||
"/tracks/users/{userId}": {
|
"/tracks/users/{userId}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -665,37 +631,9 @@ export interface components {
|
|||||||
schemas: {
|
schemas: {
|
||||||
AddPlayListTrackRequest: {
|
AddPlayListTrackRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
trackId: null | string;
|
trackId: string;
|
||||||
source: null | components["schemas"]["TrackSource"];
|
|
||||||
externalId: null | string;
|
|
||||||
};
|
};
|
||||||
AlbumApplicationResponse: components["schemas"]["AlbumApplicationResponseAlbumApplicationResponse"] | components["schemas"]["AlbumApplicationResponseExternalAlbumApplicationResponse"];
|
AlbumApplicationResponse: {
|
||||||
AlbumApplicationResponseAlbumApplicationResponse: {
|
|
||||||
/** @enum {string} */
|
|
||||||
source?: "Local";
|
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: null | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
releaseYear: null | number | string;
|
|
||||||
ownerUserId: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
trackCount: number | string;
|
|
||||||
smallImageKeyName: null | string;
|
|
||||||
mediumImageKeyName: null | string;
|
|
||||||
largeImageKeyName: null | string;
|
|
||||||
/** Format: date-time */
|
|
||||||
createdAt: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
updatedAt: string;
|
|
||||||
coverTrackIds: string[];
|
|
||||||
};
|
|
||||||
AlbumApplicationResponseExternalAlbumApplicationResponse: {
|
|
||||||
/** @enum {string} */
|
|
||||||
source?: "YouTube";
|
|
||||||
externalId: string;
|
|
||||||
thumbnailUrl: null | string;
|
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -726,9 +664,7 @@ export interface components {
|
|||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
};
|
};
|
||||||
AlbumSearchItemResponse: {
|
AlbumSearchItemResponse: {
|
||||||
source: components["schemas"]["TrackSource"];
|
album: components["schemas"]["AlbumApplicationResponse"];
|
||||||
album: null | components["schemas"]["AlbumApplicationResponse"];
|
|
||||||
youTubeAlbum: null | components["schemas"]["YouTubeAlbumResult"];
|
|
||||||
};
|
};
|
||||||
AlbumsSearchResponse: {
|
AlbumsSearchResponse: {
|
||||||
items: components["schemas"]["AlbumSearchItemResponse"][];
|
items: components["schemas"]["AlbumSearchItemResponse"][];
|
||||||
@@ -741,9 +677,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalItemCount: number | string;
|
totalItemCount: number | string;
|
||||||
hasPreviousPage: boolean;
|
hasPreviousPage: boolean;
|
||||||
hasNextLocalPage: boolean;
|
|
||||||
nextYoutubeContinuationToken: null | string;
|
|
||||||
youtubeUnavailable: boolean;
|
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
};
|
};
|
||||||
CreateAlbumRequest: {
|
CreateAlbumRequest: {
|
||||||
@@ -804,19 +737,13 @@ export interface components {
|
|||||||
items: components["schemas"]["MixItemApplicationResponse"][];
|
items: components["schemas"]["MixItemApplicationResponse"][];
|
||||||
};
|
};
|
||||||
MixItemApplicationResponse: {
|
MixItemApplicationResponse: {
|
||||||
source: components["schemas"]["MixItemSource"];
|
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
trackId: null | string;
|
trackId: string;
|
||||||
videoId: null | string;
|
|
||||||
title: string;
|
title: string;
|
||||||
artist: null | string;
|
artist: null | string;
|
||||||
thumbnailUrl: null | string;
|
|
||||||
/** Format: double */
|
/** Format: double */
|
||||||
durationSeconds: number | string;
|
durationSeconds: number | string;
|
||||||
isExplicit: boolean;
|
|
||||||
};
|
};
|
||||||
/** @enum {unknown} */
|
|
||||||
MixItemSource: "Musify" | "YouTube";
|
|
||||||
PaginatedResponseOfAlbumApplicationResponse: {
|
PaginatedResponseOfAlbumApplicationResponse: {
|
||||||
items: components["schemas"]["AlbumApplicationResponse"][];
|
items: components["schemas"]["AlbumApplicationResponse"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -928,15 +855,9 @@ export interface components {
|
|||||||
};
|
};
|
||||||
ToggleLikeRequest: {
|
ToggleLikeRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
trackId: null | string;
|
trackId: string;
|
||||||
source: null | components["schemas"]["TrackSource"];
|
|
||||||
externalId: null | string;
|
|
||||||
};
|
};
|
||||||
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
|
TrackApplicationResponse: {
|
||||||
TrackApplicationResponseExternalTrackApplicationResponse: {
|
|
||||||
/** @enum {string} */
|
|
||||||
source?: "YouTube";
|
|
||||||
externalId: string;
|
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -950,32 +871,11 @@ export interface components {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
|
||||||
TrackApplicationResponseLocalTrackApplicationResponse: {
|
|
||||||
/** @enum {string} */
|
|
||||||
source?: "Local";
|
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
/** Format: uuid */
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
artist: null | string;
|
|
||||||
audioStatus: components["schemas"]["ProcessingStatus"];
|
|
||||||
/** Format: double */
|
|
||||||
duration: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
listensCount: number | string;
|
|
||||||
/** Format: date-time */
|
|
||||||
createdAt: string;
|
|
||||||
/** Format: date-time */
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
};
|
||||||
TrackSearchItemResponse: {
|
TrackSearchItemResponse: {
|
||||||
source: components["schemas"]["TrackSource"];
|
track: components["schemas"]["TrackApplicationResponse"];
|
||||||
track: null | components["schemas"]["TrackApplicationResponse"];
|
|
||||||
youTubeSong: null | components["schemas"]["YouTubeSongResult"];
|
|
||||||
};
|
};
|
||||||
/** @enum {unknown} */
|
|
||||||
TrackSource: "Local" | "YouTube";
|
|
||||||
TracksSearchResponse: {
|
TracksSearchResponse: {
|
||||||
items: components["schemas"]["TrackSearchItemResponse"][];
|
items: components["schemas"]["TrackSearchItemResponse"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -987,9 +887,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
totalItemCount: number | string;
|
totalItemCount: number | string;
|
||||||
hasPreviousPage: boolean;
|
hasPreviousPage: boolean;
|
||||||
hasNextLocalPage: boolean;
|
|
||||||
nextYoutubeContinuationToken: null | string;
|
|
||||||
youtubeUnavailable: boolean;
|
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
};
|
};
|
||||||
TrackStreamResponse: {
|
TrackStreamResponse: {
|
||||||
@@ -1054,59 +951,6 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
followingCount: number | string;
|
followingCount: number | 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"][];
|
|
||||||
};
|
|
||||||
YouTubeAlbumTrack: {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
durationSeconds: number | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
trackNumber: number | string;
|
|
||||||
isExplicit: boolean;
|
|
||||||
};
|
|
||||||
YouTubeArtistRef: {
|
|
||||||
id: null | string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
YouTubeSongResult: {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
album: string;
|
|
||||||
/** Format: int32 */
|
|
||||||
durationSeconds: number | string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
isExplicit: boolean;
|
|
||||||
artists: components["schemas"]["YouTubeArtistRef"][];
|
|
||||||
};
|
|
||||||
/** @enum {unknown} */
|
|
||||||
YouTubeStreamMode: "Server" | "YouTube";
|
|
||||||
YouTubeStreamResponse: {
|
|
||||||
/** Format: uuid */
|
|
||||||
trackId: null | string;
|
|
||||||
mode: components["schemas"]["YouTubeStreamMode"];
|
|
||||||
streamUrl: string;
|
|
||||||
ticket: null | string;
|
|
||||||
/** Format: int32 */
|
|
||||||
expiresInSeconds: number | string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
parameters: never;
|
parameters: never;
|
||||||
@@ -1122,7 +966,6 @@ export interface operations {
|
|||||||
title?: string;
|
title?: string;
|
||||||
pageNumber?: number | string;
|
pageNumber?: number | string;
|
||||||
pageSize?: number | string;
|
pageSize?: number | string;
|
||||||
youtubeContinuationToken?: string;
|
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1217,36 +1060,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GetExternalAlbum: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
source: string;
|
|
||||||
externalId: string;
|
|
||||||
};
|
|
||||||
cookie?: never;
|
|
||||||
};
|
|
||||||
requestBody?: never;
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/json": components["schemas"]["YouTubeAlbumDetail"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Not Found */
|
|
||||||
404: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
GetAlbumsByUserId: {
|
GetAlbumsByUserId: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1648,15 +1461,6 @@ export interface operations {
|
|||||||
"application/json": boolean;
|
"application/json": boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
|
||||||
400: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
401: {
|
401: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1918,15 +1722,6 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["TrackApplicationResponse"];
|
"application/json": components["schemas"]["TrackApplicationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Bad Request */
|
|
||||||
400: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content: {
|
|
||||||
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/** @description Unauthorized */
|
/** @description Unauthorized */
|
||||||
401: {
|
401: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2150,7 +1945,6 @@ export interface operations {
|
|||||||
name?: string;
|
name?: string;
|
||||||
pageNumber?: number | string;
|
pageNumber?: number | string;
|
||||||
pageSize?: number | string;
|
pageSize?: number | string;
|
||||||
youtubeContinuationToken?: string;
|
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -2317,50 +2111,6 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
GetExternalTrackStream: {
|
|
||||||
parameters: {
|
|
||||||
query?: never;
|
|
||||||
header?: never;
|
|
||||||
path: {
|
|
||||||
source: string;
|
|
||||||
externalId: 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;
|
|
||||||
};
|
|
||||||
/** @description Conflict */
|
|
||||||
409: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown;
|
|
||||||
};
|
|
||||||
content?: never;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
GetTracksByUserId: {
|
GetTracksByUserId: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -2,18 +2,11 @@
|
|||||||
const MENU_WIDTH = 240;
|
const MENU_WIDTH = 240;
|
||||||
const SUBMENU_WIDTH = 256;
|
const SUBMENU_WIDTH = 256;
|
||||||
|
|
||||||
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
|
export type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
||||||
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
|
|
||||||
);
|
|
||||||
|
|
||||||
export function albumContextMenuStateFor(
|
export function albumContextMenuStateFor(event: MouseEvent, albumId: string): AlbumMenuState {
|
||||||
event: MouseEvent,
|
|
||||||
kind: 'local' | 'youtube',
|
|
||||||
albumId: string
|
|
||||||
): AlbumMenuState {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return {
|
return {
|
||||||
kind,
|
|
||||||
albumId,
|
albumId,
|
||||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
||||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
||||||
@@ -93,9 +86,7 @@
|
|||||||
{#each playlists as playlist (playlist.id)}
|
{#each playlists as playlist (playlist.id)}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={menu.kind === 'youtube'
|
action="?/addAlbumToPlaylist"
|
||||||
? '?/addYoutubeAlbumToPlaylist'
|
|
||||||
: '?/addAlbumToPlaylist'}
|
|
||||||
use:enhance={() =>
|
use:enhance={() =>
|
||||||
({ update }) => {
|
({ update }) => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
{#each playlists as playlist (playlist.id)}
|
{#each playlists as playlist (playlist.id)}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
|
action="?/addTrack"
|
||||||
use:enhance={() =>
|
use:enhance={() =>
|
||||||
({ update }) => {
|
({ update }) => {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -86,15 +86,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||||
{#if menu.kind === 'youtube'}
|
<input type="hidden" name="trackId" value={menu.track.id} />
|
||||||
<input type="hidden" name="videoId" value={menu.song.videoId} />
|
|
||||||
<input type="hidden" name="title" value={menu.song.title} />
|
|
||||||
<input type="hidden" name="artist" value={menu.song.artist} />
|
|
||||||
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
|
|
||||||
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
|
|
||||||
{:else}
|
|
||||||
<input type="hidden" name="trackId" value={menu.track.id} />
|
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||||
<Cover
|
<Cover
|
||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
src={player.current.coverUrl}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={player.current.title}
|
alt={player.current.title}
|
||||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
class="h-10 w-10 shrink-0 rounded-[9px]"
|
||||||
@@ -90,7 +89,6 @@
|
|||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
src={track.coverUrl}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
||||||
@@ -104,9 +102,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if track.duration}
|
{#if track.duration}
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums"
|
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span>
|
||||||
>{fmtTime(track.duration)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
id: player.current.id,
|
id: player.current.id,
|
||||||
title: player.current.title,
|
title: player.current.title,
|
||||||
artist: player.current.artist,
|
artist: player.current.artist,
|
||||||
coverUrl: player.current.coverUrl,
|
|
||||||
source: player.current.source,
|
|
||||||
explicit: player.current.explicit,
|
explicit: player.current.explicit,
|
||||||
ownerUserId: player.current.ownerUserId
|
ownerUserId: player.current.ownerUserId
|
||||||
});
|
});
|
||||||
@@ -32,7 +30,6 @@
|
|||||||
{#if player.current.id}
|
{#if player.current.id}
|
||||||
<Cover
|
<Cover
|
||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
src={player.current.coverUrl}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={player.current.title}
|
alt={player.current.title}
|
||||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-cover-xs md:h-12 md:w-12"
|
class="h-11 w-11 shrink-0 rounded-[11px] shadow-cover-xs md:h-12 md:w-12"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { thumbnailSrc } from '$lib/thumbnails';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId: string | number;
|
trackId: string | number;
|
||||||
@@ -24,9 +23,7 @@
|
|||||||
|
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
|
|
||||||
const imageSrc = $derived(
|
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
|
||||||
src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}`
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
imageSrc;
|
imageSrc;
|
||||||
|
|||||||
@@ -15,21 +15,11 @@
|
|||||||
{#if tiles.length >= 4}
|
{#if tiles.length >= 4}
|
||||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||||
{#each tiles as item (mixItemKey(item))}
|
{#each tiles as item (mixItemKey(item))}
|
||||||
<Cover
|
<Cover trackId={item.trackId} size="small" class="h-full w-full" />
|
||||||
trackId={item.trackId ?? item.videoId ?? ''}
|
|
||||||
src={item.thumbnailUrl}
|
|
||||||
size="small"
|
|
||||||
class="h-full w-full"
|
|
||||||
/>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if tiles.length > 0}
|
{:else if tiles.length > 0}
|
||||||
<Cover
|
<Cover trackId={tiles[0].trackId} size="medium" class={klass} />
|
||||||
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
|
|
||||||
src={tiles[0].thumbnailUrl}
|
|
||||||
size="medium"
|
|
||||||
class={klass}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bg-surface {klass}"></div>
|
<div class="bg-surface {klass}"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
class?: string;
|
|
||||||
children: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { class: klass = '', children }: Props = $props();
|
|
||||||
|
|
||||||
let track: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
export function scrollByPage(direction: 1 | -1) {
|
|
||||||
track?.scrollBy({ left: direction * track.clientWidth * 0.85, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={track} class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.no-scrollbar {
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,8 +13,6 @@ export const HOME_MIXES_LIMIT = 6;
|
|||||||
export const HOME_SHELF_LIMIT = 6;
|
export const HOME_SHELF_LIMIT = 6;
|
||||||
export const HOME_CONTINUE_LIMIT = 6;
|
export const HOME_CONTINUE_LIMIT = 6;
|
||||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
|
||||||
export const HOME_POPULAR_FILLER_LIMIT = 10;
|
|
||||||
export const HOME_POPULAR_MAX = 8;
|
export const HOME_POPULAR_MAX = 8;
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||||
|
|||||||
@@ -5,33 +5,15 @@ export type Mix = components['schemas']['MixApplicationResponse'];
|
|||||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
||||||
|
|
||||||
export function mixItemKey(item: MixItem): string {
|
export function mixItemKey(item: MixItem): string {
|
||||||
return item.source === 'YouTube' ? `yt:${item.videoId}` : `mf:${item.trackId}`;
|
return `mf:${item.trackId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mixItemTarget(item: MixItem): TrackTarget {
|
export function mixItemTarget(item: MixItem): TrackTarget {
|
||||||
if (item.source === 'YouTube') {
|
|
||||||
return {
|
|
||||||
kind: 'youtube',
|
|
||||||
song: {
|
|
||||||
videoId: item.videoId ?? '',
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist ?? '',
|
|
||||||
album: '',
|
|
||||||
durationSeconds: Number(item.durationSeconds),
|
|
||||||
thumbnailUrl: item.thumbnailUrl ?? '',
|
|
||||||
isExplicit: item.isExplicit
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'local',
|
|
||||||
track: {
|
track: {
|
||||||
id: item.trackId ?? '',
|
id: item.trackId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist,
|
artist: item.artist
|
||||||
source: 'Local',
|
|
||||||
isExplicit: item.isExplicit
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const LABELS: [RegExp, string][] = [
|
|||||||
[/^\/explore\/?$/, 'Volver a explorar'],
|
[/^\/explore\/?$/, 'Volver a explorar'],
|
||||||
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
|
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
|
||||||
[/^\/playlists\/?$/, 'Volver a tus listas'],
|
[/^\/playlists\/?$/, 'Volver a tus listas'],
|
||||||
[/^\/albums\/youtube\/[^/]+/, 'Volver al álbum'],
|
|
||||||
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
||||||
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
||||||
[/^\/library\/?$/, 'Volver a tus canciones'],
|
[/^\/library\/?$/, 'Volver a tus canciones'],
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
|
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
||||||
|
|
||||||
export interface LikedTrack {
|
export interface LikedTrack {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
coverUrl?: string;
|
|
||||||
source?: TrackSourceKind;
|
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
likedAt: number;
|
likedAt: number;
|
||||||
@@ -15,18 +13,10 @@ type LikeToggleInput = {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
coverUrl?: string;
|
|
||||||
source?: TrackSourceKind;
|
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toggleRequestBody(track: LikeToggleInput) {
|
|
||||||
return track.source === 'youtube'
|
|
||||||
? { source: 'YouTube' as const, externalId: String(track.id) }
|
|
||||||
: { trackId: String(track.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
class LikedStore {
|
class LikedStore {
|
||||||
entries = $state<Record<string, LikedTrack>>({});
|
entries = $state<Record<string, LikedTrack>>({});
|
||||||
private hydrated = false;
|
private hydrated = false;
|
||||||
@@ -45,8 +35,6 @@ class LikedStore {
|
|||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
coverUrl: item.coverUrl,
|
|
||||||
source: item.source,
|
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId,
|
ownerUserId: item.ownerUserId,
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||||
@@ -70,8 +58,6 @@ class LikedStore {
|
|||||||
id,
|
id,
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
coverUrl: track.coverUrl,
|
|
||||||
source: track.source,
|
|
||||||
explicit: track.explicit,
|
explicit: track.explicit,
|
||||||
ownerUserId: track.ownerUserId,
|
ownerUserId: track.ownerUserId,
|
||||||
likedAt: Date.now()
|
likedAt: Date.now()
|
||||||
@@ -83,7 +69,7 @@ class LikedStore {
|
|||||||
const res = await fetch('/api/likes', {
|
const res = await fetch('/api/likes', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify(toggleRequestBody(track))
|
body: JSON.stringify({ trackId: id })
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed to toggle like');
|
if (!res.ok) throw new Error('Failed to toggle like');
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
import { thumbnailSrc } from '$lib/thumbnails';
|
|
||||||
|
|
||||||
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;
|
||||||
source: TrackSourceKind;
|
|
||||||
coverUrl?: string;
|
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
}
|
}
|
||||||
@@ -26,8 +21,6 @@ export interface QueueItem {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
source?: TrackSourceKind;
|
|
||||||
coverUrl?: string;
|
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
}
|
}
|
||||||
@@ -36,46 +29,18 @@ export interface ApiTrackLike {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
source?: 'Local' | 'YouTube';
|
|
||||||
externalId?: string | null;
|
|
||||||
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
|
||||||
isExplicit?: boolean;
|
isExplicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isYouTubeTrack(track: ApiTrackLike): boolean {
|
|
||||||
return track.source === 'YouTube';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPendingYouTubeTrack(track: ApiTrackLike): boolean {
|
|
||||||
return isYouTubeTrack(track) && track.audioStatus !== 'Completed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function youTubeThumbnailUrl(videoId: string): string {
|
|
||||||
return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function queueIdForTrack(track: ApiTrackLike): string | number {
|
|
||||||
return isYouTubeTrack(track) && track.externalId ? track.externalId : track.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||||
return tracks.map((track) => {
|
return tracks.map((track) => ({
|
||||||
const youTube = isYouTubeTrack(track);
|
id: track.id,
|
||||||
return {
|
title: track.title,
|
||||||
id: queueIdForTrack(track),
|
artist: track.artist ?? undefined,
|
||||||
title: track.title,
|
explicit: track.isExplicit,
|
||||||
artist: track.artist ?? undefined,
|
ownerUserId: track.ownerUserId
|
||||||
source: youTube ? 'youtube' : 'local',
|
}));
|
||||||
coverUrl: youTube
|
|
||||||
? isPendingYouTubeTrack(track) && track.externalId
|
|
||||||
? youTubeThumbnailUrl(track.externalId)
|
|
||||||
: `/api/tracks/${track.id}/cover?size=small`
|
|
||||||
: undefined,
|
|
||||||
explicit: track.isExplicit,
|
|
||||||
ownerUserId: youTube ? undefined : track.ownerUserId
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readErrorMessage(res: Response): Promise<string> {
|
async function readErrorMessage(res: Response): Promise<string> {
|
||||||
@@ -93,8 +58,7 @@ const EMPTY: PlayerTrack = {
|
|||||||
id: '',
|
id: '',
|
||||||
title: '',
|
title: '',
|
||||||
artist: '',
|
artist: '',
|
||||||
duration: 0,
|
duration: 0
|
||||||
source: 'local'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toTrack(item: QueueItem): PlayerTrack {
|
function toTrack(item: QueueItem): PlayerTrack {
|
||||||
@@ -104,8 +68,6 @@ function toTrack(item: QueueItem): PlayerTrack {
|
|||||||
artist: item.artist ?? '',
|
artist: item.artist ?? '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
source: item.source ?? 'local',
|
|
||||||
coverUrl: item.coverUrl,
|
|
||||||
ownerUserId: item.ownerUserId
|
ownerUserId: item.ownerUserId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -127,7 +89,6 @@ 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;
|
||||||
|
|
||||||
@@ -170,15 +131,6 @@ 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.';
|
||||||
@@ -207,12 +159,10 @@ class PlayerState {
|
|||||||
navigator.mediaSession.metadata = null;
|
navigator.mediaSession.metadata = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const artwork =
|
|
||||||
thumbnailSrc(track.coverUrl, 'medium') ?? `/api/tracks/${track.id}/cover?size=small`;
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
artwork: [{ src: artwork, sizes: '256x256' }]
|
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,16 +202,14 @@ class PlayerState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #applyAccent(id: string | number, coverUrl?: string) {
|
async #applyAccent(id: string | number) {
|
||||||
const key = String(id);
|
const key = String(id);
|
||||||
const cached = this.#accentCache.get(key);
|
const cached = this.#accentCache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
this.accentColor = cached.accent;
|
this.accentColor = cached.accent;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await extractAccent(
|
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||||
thumbnailSrc(coverUrl, 'small') ?? `/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);
|
||||||
@@ -276,23 +224,19 @@ class PlayerState {
|
|||||||
if (!audio || this.currentId === null) return;
|
if (!audio || this.currentId === null) return;
|
||||||
const id = this.currentId;
|
const id = this.currentId;
|
||||||
const track = this.tracks.find((t) => t.id === id);
|
const track = this.tracks.find((t) => t.id === id);
|
||||||
this.#applyAccent(id, track?.coverUrl);
|
this.#applyAccent(id);
|
||||||
if (track) this.#syncMediaSessionMetadata(track);
|
if (track) this.#syncMediaSessionMetadata(track);
|
||||||
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 src =
|
const src = await this.#resolveLocalSrc(String(id));
|
||||||
track?.source === 'youtube'
|
|
||||||
? await this.#resolveYouTubeSrc(String(id))
|
|
||||||
: await this.#resolveLocalSrc(String(id));
|
|
||||||
if (token !== this.#loadToken) return;
|
if (token !== this.#loadToken) return;
|
||||||
audio.src = src;
|
audio.src = src;
|
||||||
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 (exception) {
|
} catch (exception) {
|
||||||
console.error('playback failed', exception);
|
console.error('playback failed', exception);
|
||||||
@@ -316,17 +260,6 @@ class PlayerState {
|
|||||||
return this.#withTicket(manifestUrl, ticket);
|
return this.#withTicket(manifestUrl, ticket);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #resolveYouTubeSrc(videoId: string): Promise<string> {
|
|
||||||
const res = await fetch(`/api/tracks/external/youtube/${videoId}/stream`);
|
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res));
|
|
||||||
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 {
|
#withTicket(url: string, ticket: string): string {
|
||||||
if (!ticket) return url;
|
if (!ticket) return url;
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
const sep = url.includes('?') ? '&' : '?';
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
|||||||
id,
|
id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist,
|
artist: t.artist,
|
||||||
source: t.source,
|
|
||||||
coverUrl: t.coverUrl,
|
|
||||||
explicit: t.explicit,
|
explicit: t.explicit,
|
||||||
ownerUserId: t.ownerUserId
|
ownerUserId: t.ownerUserId
|
||||||
});
|
});
|
||||||
@@ -31,8 +29,6 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
|||||||
id,
|
id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
source: item.source ?? 'local',
|
|
||||||
coverUrl: item.coverUrl,
|
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId
|
ownerUserId: item.ownerUserId
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,30 +51,3 @@ export const apiConfig = {
|
|||||||
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
|
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const thumbnailConfig = {
|
|
||||||
get maxAgeSeconds() {
|
|
||||||
return positiveInt(env.THUMBNAIL_CACHE_MAX_AGE_SECONDS, 30 * DAY_SECONDS);
|
|
||||||
},
|
|
||||||
get failureCacheSeconds() {
|
|
||||||
return positiveInt(env.THUMBNAIL_FAILURE_CACHE_SECONDS, 300);
|
|
||||||
},
|
|
||||||
get memoryCacheBytes() {
|
|
||||||
return positiveInt(env.THUMBNAIL_CACHE_BYTES, 128 * 1024 * 1024);
|
|
||||||
},
|
|
||||||
get maxConcurrentFetches() {
|
|
||||||
return positiveInt(env.THUMBNAIL_MAX_CONCURRENT_FETCHES, 4);
|
|
||||||
},
|
|
||||||
get fetchTimeoutMs() {
|
|
||||||
return positiveInt(env.THUMBNAIL_FETCH_TIMEOUT_MS, 8000);
|
|
||||||
},
|
|
||||||
get cooldownSeconds() {
|
|
||||||
return positiveInt(env.THUMBNAIL_COOLDOWN_SECONDS, 30);
|
|
||||||
},
|
|
||||||
get maxCooldownSeconds() {
|
|
||||||
return positiveInt(env.THUMBNAIL_MAX_COOLDOWN_SECONDS, 300);
|
|
||||||
},
|
|
||||||
get maxSize() {
|
|
||||||
return positiveInt(env.THUMBNAIL_MAX_SIZE, 720);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
|||||||
const api = createApiClient({ fetch, accessToken });
|
const api = createApiClient({ fetch, accessToken });
|
||||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||||
params: { path: { playlistId } },
|
params: { path: { playlistId } },
|
||||||
body: { trackId, source: null, externalId: null }
|
body: { trackId }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -30,34 +30,6 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
|
|||||||
return { added: true, trackId, playlistId };
|
return { added: true, trackId, playlistId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addYouTubeToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
|
||||||
const videoId = String(form.get('videoId') ?? '');
|
|
||||||
|
|
||||||
if (!playlistId || !videoId) {
|
|
||||||
return fail(400, { message: 'Faltan datos de la canción.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
|
|
||||||
params: { path: { playlistId } },
|
|
||||||
body: { trackId: null, source: 'YouTube', externalId: videoId }
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
const accessToken = requireAccessTokenAction(locals);
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
if (typeof accessToken !== 'string') return accessToken;
|
||||||
@@ -83,45 +55,7 @@ export async function addAlbumToPlaylistAction({ request, params, locals, fetch
|
|||||||
for (const track of tracks.items) {
|
for (const track of tracks.items) {
|
||||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
||||||
params: { path: { playlistId } },
|
params: { path: { playlistId } },
|
||||||
body: { trackId: String(track.id), source: null, externalId: null }
|
body: { trackId: String(track.id) }
|
||||||
});
|
|
||||||
if (!err) added++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { addedAlbum: true, albumId, playlistId, added };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addYoutubeAlbumToPlaylistAction({
|
|
||||||
request,
|
|
||||||
params,
|
|
||||||
locals,
|
|
||||||
fetch
|
|
||||||
}: RequestEvent) {
|
|
||||||
const accessToken = requireAccessTokenAction(locals);
|
|
||||||
if (typeof accessToken !== 'string') return accessToken;
|
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
|
|
||||||
const albumId = String(form.get('albumId') ?? '');
|
|
||||||
|
|
||||||
if (!playlistId || !albumId) {
|
|
||||||
return fail(400, { message: 'Faltan datos del álbum.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken });
|
|
||||||
const { data: detail, error: detailErr } = await api.GET('/albums/external/{source}/{externalId}', {
|
|
||||||
params: { path: { source: 'YouTube', externalId: albumId } }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (detailErr || !detail) {
|
|
||||||
return fail(502, { message: 'No se pudo cargar el álbum de YouTube.', albumId });
|
|
||||||
}
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
for (const track of detail.tracks) {
|
|
||||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
|
|
||||||
params: { path: { playlistId } },
|
|
||||||
body: { trackId: null, source: 'YouTube', externalId: track.videoId }
|
|
||||||
});
|
});
|
||||||
if (!err) added++;
|
if (!err) added++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import { thumbnailConfig } from '$lib/server/config';
|
|
||||||
|
|
||||||
const ALLOWED_HOST_SUFFIXES = ['.googleusercontent.com', '.ytimg.com', '.ggpht.com'];
|
|
||||||
|
|
||||||
export interface ThumbnailImage {
|
|
||||||
body: ArrayBuffer;
|
|
||||||
contentType: string;
|
|
||||||
etag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CacheEntry {
|
|
||||||
image: ThumbnailImage | null;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = new Map<string, CacheEntry>();
|
|
||||||
const inFlight = new Map<string, Promise<ThumbnailImage | null>>();
|
|
||||||
|
|
||||||
let cachedBytes = 0;
|
|
||||||
let cooldownUntil = 0;
|
|
||||||
let active = 0;
|
|
||||||
const waiting: (() => void)[] = [];
|
|
||||||
|
|
||||||
export function parseThumbnailUrl(rawUrl: string): URL | null {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(rawUrl);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== 'https:') return null;
|
|
||||||
if (!ALLOWED_HOST_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix))) return null;
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resizeThumbnailUrl(url: URL, size: number): string {
|
|
||||||
if (url.hostname.endsWith('.googleusercontent.com')) {
|
|
||||||
const raw = url.toString();
|
|
||||||
const separator = raw.lastIndexOf('=');
|
|
||||||
const base =
|
|
||||||
separator > 0 && /^[wsh]\d/.test(raw.slice(separator + 1)) ? raw.slice(0, separator) : raw;
|
|
||||||
return `${base}=w${size}-h${size}-l90-rj`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.hostname.endsWith('.ytimg.com')) {
|
|
||||||
const path = url.origin + url.pathname;
|
|
||||||
const lastSlash = path.lastIndexOf('/');
|
|
||||||
if (lastSlash < 0) return path;
|
|
||||||
return `${path.slice(0, lastSlash)}/${ytImgFileName(size)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ytImgFileName(size: number): string {
|
|
||||||
if (size <= 90) return 'default.jpg';
|
|
||||||
if (size <= 180) return 'mqdefault.jpg';
|
|
||||||
if (size <= 360) return 'hqdefault.jpg';
|
|
||||||
return 'maxresdefault.jpg';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadThumbnail(url: URL, size: number): Promise<ThumbnailImage | null> {
|
|
||||||
const target = resizeThumbnailUrl(url, size);
|
|
||||||
|
|
||||||
const cached = cache.get(target);
|
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
|
||||||
touch(target, cached);
|
|
||||||
return cached.image;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = inFlight.get(target);
|
|
||||||
if (pending) return pending;
|
|
||||||
|
|
||||||
const request = fetchThumbnail(target).finally(() => inFlight.delete(target));
|
|
||||||
inFlight.set(target, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchThumbnail(target: string): Promise<ThumbnailImage | null> {
|
|
||||||
if (Date.now() < cooldownUntil) return null;
|
|
||||||
|
|
||||||
await acquire();
|
|
||||||
try {
|
|
||||||
const response = await fetch(target, {
|
|
||||||
headers: { Accept: 'image/webp,image/avif,image/*;q=0.8' },
|
|
||||||
signal: AbortSignal.timeout(thumbnailConfig.fetchTimeoutMs)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 429 || response.status === 503) {
|
|
||||||
cooldownUntil = Date.now() + retryAfterMs(response);
|
|
||||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
const image: ThumbnailImage = {
|
|
||||||
body: buffer,
|
|
||||||
contentType: response.headers.get('content-type') ?? 'image/jpeg',
|
|
||||||
etag: `W/"${buffer.byteLength.toString(16)}-${hash(target).toString(16)}"`
|
|
||||||
};
|
|
||||||
|
|
||||||
store(target, image, thumbnailConfig.maxAgeSeconds);
|
|
||||||
return image;
|
|
||||||
} catch {
|
|
||||||
store(target, null, thumbnailConfig.failureCacheSeconds);
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryAfterMs(response: Response): number {
|
|
||||||
const header = Number(response.headers.get('retry-after'));
|
|
||||||
const seconds = Number.isFinite(header) && header > 0 ? header : thumbnailConfig.cooldownSeconds;
|
|
||||||
return Math.min(seconds, thumbnailConfig.maxCooldownSeconds) * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function store(target: string, image: ThumbnailImage | null, ttlSeconds: number) {
|
|
||||||
const previous = cache.get(target);
|
|
||||||
if (previous?.image) cachedBytes -= previous.image.body.byteLength;
|
|
||||||
|
|
||||||
cache.delete(target);
|
|
||||||
cache.set(target, { image, expiresAt: Date.now() + ttlSeconds * 1000 });
|
|
||||||
if (image) cachedBytes += image.body.byteLength;
|
|
||||||
|
|
||||||
evict();
|
|
||||||
}
|
|
||||||
|
|
||||||
function touch(target: string, entry: CacheEntry) {
|
|
||||||
cache.delete(target);
|
|
||||||
cache.set(target, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
function evict() {
|
|
||||||
while (cachedBytes > thumbnailConfig.memoryCacheBytes) {
|
|
||||||
const oldest = cache.keys().next();
|
|
||||||
if (oldest.done) return;
|
|
||||||
|
|
||||||
const entry = cache.get(oldest.value);
|
|
||||||
if (entry?.image) cachedBytes -= entry.image.body.byteLength;
|
|
||||||
cache.delete(oldest.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function acquire(): Promise<void> {
|
|
||||||
if (active < thumbnailConfig.maxConcurrentFetches) {
|
|
||||||
active++;
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => waiting.push(resolve));
|
|
||||||
}
|
|
||||||
|
|
||||||
function release() {
|
|
||||||
const next = waiting.shift();
|
|
||||||
if (next) next();
|
|
||||||
else active--;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hash(value: string): number {
|
|
||||||
let result = 2166136261;
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
result ^= value.charCodeAt(i);
|
|
||||||
result = Math.imul(result, 16777619);
|
|
||||||
}
|
|
||||||
return result >>> 0;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { createApiClient } from '$lib/server/api';
|
|
||||||
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
|
|
||||||
|
|
||||||
export const YOUTUBE_FILLER_QUERIES = [
|
|
||||||
'pop',
|
|
||||||
'rock',
|
|
||||||
'reggaeton',
|
|
||||||
'lo-fi',
|
|
||||||
'indie',
|
|
||||||
'salsa',
|
|
||||||
'k-pop',
|
|
||||||
'electrónica',
|
|
||||||
'jazz',
|
|
||||||
'baladas',
|
|
||||||
'trap',
|
|
||||||
'cumbia',
|
|
||||||
'hip hop',
|
|
||||||
'música clásica'
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface YoutubeFiller {
|
|
||||||
query: string;
|
|
||||||
items: YouTubeSong[];
|
|
||||||
continuationToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YoutubeAlbumFiller {
|
|
||||||
query: string;
|
|
||||||
items: YouTubeAlbumResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickRandomFillerQuery(): string {
|
|
||||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hash(text: string): number {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
h = (h * 31 + text.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return Math.abs(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFillerQueryForUser(userId: string, date: Date, variant = 0): string {
|
|
||||||
const dayKey = date.toISOString().slice(0, 10);
|
|
||||||
const index = (hash(`${userId}:${dayKey}`) + variant) % YOUTUBE_FILLER_QUERIES.length;
|
|
||||||
return YOUTUBE_FILLER_QUERIES[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchYoutubeFiller(
|
|
||||||
api: ReturnType<typeof createApiClient>,
|
|
||||||
accessToken: string | null | undefined,
|
|
||||||
limit: number,
|
|
||||||
seedQuery?: string,
|
|
||||||
userId?: string,
|
|
||||||
variant = 0
|
|
||||||
): Promise<YoutubeFiller> {
|
|
||||||
if (!accessToken) return { query: '', items: [], continuationToken: '' };
|
|
||||||
const query =
|
|
||||||
seedQuery?.trim() ||
|
|
||||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
|
||||||
const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } });
|
|
||||||
const items = (data?.items ?? [])
|
|
||||||
.map((item) => item.youTubeSong)
|
|
||||||
.filter((song): song is NonNullable<typeof song> => song !== null && song !== undefined);
|
|
||||||
return {
|
|
||||||
query,
|
|
||||||
items: (items as YouTubeSong[]).slice(0, limit),
|
|
||||||
continuationToken: data?.nextYoutubeContinuationToken ?? ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchYoutubeAlbumFiller(
|
|
||||||
api: ReturnType<typeof createApiClient>,
|
|
||||||
accessToken: string | null | undefined,
|
|
||||||
limit: number,
|
|
||||||
seedQuery?: string,
|
|
||||||
userId?: string,
|
|
||||||
variant = 0
|
|
||||||
): Promise<YoutubeAlbumFiller> {
|
|
||||||
if (!accessToken) return { query: '', items: [] };
|
|
||||||
const query =
|
|
||||||
seedQuery?.trim() ||
|
|
||||||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
|
|
||||||
const { data } = await api.GET('/albums', {
|
|
||||||
params: { query: { title: query, pageNumber: 1, pageSize: limit + 5 } }
|
|
||||||
});
|
|
||||||
const items = (data?.items ?? [])
|
|
||||||
.map((item) => item.youTubeAlbum)
|
|
||||||
.filter((album): album is NonNullable<typeof album> => album !== null && album !== undefined);
|
|
||||||
return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) };
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export const THUMBNAIL_SIZES = {
|
|
||||||
small: 128,
|
|
||||||
medium: 192,
|
|
||||||
large: 360
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
|
||||||
|
|
||||||
const PROXY_PATH = '/api/thumbnail';
|
|
||||||
|
|
||||||
export function thumbnailSrc(
|
|
||||||
url: string | undefined,
|
|
||||||
size: ThumbnailSize = 'large'
|
|
||||||
): string | undefined {
|
|
||||||
if (!url) return undefined;
|
|
||||||
if (!url.startsWith('http')) return url;
|
|
||||||
|
|
||||||
return `${PROXY_PATH}?u=${encodeURIComponent(url)}&size=${THUMBNAIL_SIZES[size]}`;
|
|
||||||
}
|
|
||||||
@@ -1,87 +1,48 @@
|
|||||||
import {
|
import {
|
||||||
player,
|
player,
|
||||||
queueIdForTrack,
|
|
||||||
toQueueItems,
|
toQueueItems,
|
||||||
type ApiTrackLike,
|
type ApiTrackLike,
|
||||||
type QueueItem
|
type QueueItem
|
||||||
} from '$lib/player/player.svelte';
|
} from '$lib/player/player.svelte';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
|
||||||
|
|
||||||
export type TrackTarget =
|
export type TrackTarget = { track: ApiTrackLike };
|
||||||
{ kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
|
|
||||||
|
|
||||||
export function targetId(target: TrackTarget): string | number {
|
export function targetId(target: TrackTarget): string | number {
|
||||||
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
|
return target.track.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetTitle(target: TrackTarget): string {
|
export function targetTitle(target: TrackTarget): string {
|
||||||
return target.kind === 'youtube' ? target.song.title : target.track.title;
|
return target.track.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetArtist(target: TrackTarget): string | undefined {
|
export function targetArtist(target: TrackTarget): string | undefined {
|
||||||
return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined;
|
return target.track.artist ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
|
||||||
return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined);
|
return target.track.ownerUserId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetExplicit(target: TrackTarget): boolean {
|
export function targetExplicit(target: TrackTarget): boolean {
|
||||||
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
return target.track.isExplicit ?? false;
|
||||||
}
|
|
||||||
|
|
||||||
export function targetDurationSeconds(target: TrackTarget): number | undefined {
|
|
||||||
return target.kind === 'youtube' ? target.song.durationSeconds : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function targetCoverSrc(target: TrackTarget): string | undefined {
|
|
||||||
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueIdForTarget(target: TrackTarget): string | number {
|
|
||||||
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTargetCurrent(target: TrackTarget): boolean {
|
export function isTargetCurrent(target: TrackTarget): boolean {
|
||||||
return player.current.id === queueIdForTarget(target);
|
return player.current.id === target.track.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queueItemForTarget(target: TrackTarget): QueueItem {
|
export function queueItemForTarget(target: TrackTarget): QueueItem {
|
||||||
return target.kind === 'youtube'
|
return toQueueItems([target.track])[0];
|
||||||
? {
|
|
||||||
id: target.song.videoId,
|
|
||||||
title: target.song.title,
|
|
||||||
artist: target.song.artist,
|
|
||||||
source: 'youtube',
|
|
||||||
coverUrl: target.song.thumbnailUrl,
|
|
||||||
explicit: target.song.isExplicit
|
|
||||||
}
|
|
||||||
: toQueueItems([target.track])[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function targetForQueueItem(item: QueueItem): TrackTarget {
|
export function targetForQueueItem(item: QueueItem): TrackTarget {
|
||||||
return item.source === 'youtube'
|
return {
|
||||||
? {
|
track: {
|
||||||
kind: 'youtube',
|
id: item.id,
|
||||||
song: {
|
title: item.title,
|
||||||
videoId: String(item.id),
|
artist: item.artist,
|
||||||
title: item.title,
|
isExplicit: item.explicit,
|
||||||
artist: item.artist ?? '',
|
ownerUserId: item.ownerUserId
|
||||||
album: '',
|
}
|
||||||
durationSeconds: 0,
|
};
|
||||||
thumbnailUrl: item.coverUrl ?? '',
|
|
||||||
isExplicit: item.explicit ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: 'local',
|
|
||||||
track: {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
source: 'Local',
|
|
||||||
isExplicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,3 @@ export interface SessionUser {
|
|||||||
email: string;
|
email: string;
|
||||||
picture: string;
|
picture: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YouTubeSong {
|
|
||||||
videoId: string;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
album: string;
|
|
||||||
durationSeconds: number;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
isExplicit: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YouTubeAlbumResult {
|
|
||||||
albumId: string;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
thumbnailUrl: string;
|
|
||||||
releaseYear: number | string | null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
|
|
||||||
const tracks = tracksRes.data?.items ?? [];
|
const tracks = tracksRes.data?.items ?? [];
|
||||||
const inAlbum = new Set(tracks.map((track) => track.id));
|
const inAlbum = new Set(tracks.map((track) => track.id));
|
||||||
const library = (libraryRes?.data?.items ?? []).filter(
|
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id));
|
||||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
@@ -19,9 +19,7 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(
|
const isCurrentQueue = $derived(tracks.some((track) => track.id === player.current.id));
|
||||||
tracks.some((track) => queueIdForTrack(track) === player.current.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import {
|
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
||||||
player,
|
|
||||||
queueIdForTrack,
|
|
||||||
toQueueItems,
|
|
||||||
type ApiTrackLike
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||||
@@ -31,7 +26,7 @@
|
|||||||
|
|
||||||
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
|
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === queueIdForTrack(track)}
|
{@const active = player.current.id === track.id}
|
||||||
<TrackRow {active}>
|
<TrackRow {active}>
|
||||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
||||||
<TrackTitleCell
|
<TrackTitleCell
|
||||||
|
|||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
|
||||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
|
||||||
import {
|
|
||||||
addYouTubeToPlaylistAction,
|
|
||||||
addYoutubeAlbumToPlaylistAction
|
|
||||||
} from '$lib/server/playlistActions';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
|
||||||
const { allowAnonymousListening } = await parent();
|
|
||||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
|
||||||
|
|
||||||
const [albumRes, playlistsRes] = await Promise.all([
|
|
||||||
api.GET('/albums/external/{source}/{externalId}', {
|
|
||||||
params: { path: { source: params.source, externalId: params.externalId } }
|
|
||||||
}),
|
|
||||||
user
|
|
||||||
? api.GET('/playlists/users/{userId}', {
|
|
||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
|
||||||
})
|
|
||||||
: Promise.resolve(null)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum.', 404);
|
|
||||||
|
|
||||||
return { album, playlists: playlistsRes?.data?.items ?? [], section: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
|
||||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
|
||||||
};
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
|
||||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime } from '$lib/format';
|
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
|
||||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.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 AlbumContextMenu, {
|
|
||||||
albumContextMenuStateFor,
|
|
||||||
type AlbumMenuState
|
|
||||||
} from '$lib/components/AlbumContextMenu.svelte';
|
|
||||||
import type { TrackTarget } from '$lib/tracks';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
|
||||||
|
|
||||||
const album = $derived(data.album.album);
|
|
||||||
const tracks = $derived(data.album.tracks);
|
|
||||||
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
|
||||||
let albumMenu = $state<AlbumMenuState | 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}</title>
|
|
||||||
<meta name="description" content="Álbum {album.title} de {album.artist}," />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<Page>
|
|
||||||
<BackLink href="/explore" label="Volver a explorar" />
|
|
||||||
|
|
||||||
<CollectionHeader
|
|
||||||
eyebrow={album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
|
|
||||||
title={album.title}
|
|
||||||
description={album.artist}
|
|
||||||
meta={[
|
|
||||||
album.releaseYear ? String(album.releaseYear) : undefined,
|
|
||||||
`${tracks.length} ${tracks.length === 1 ? 'canción' : 'canciones'}`,
|
|
||||||
fmtTime(Number(data.album.totalDurationSeconds))
|
|
||||||
]
|
|
||||||
.filter((part) => !!part)
|
|
||||||
.join(' · ')}
|
|
||||||
>
|
|
||||||
{#snippet cover()}
|
|
||||||
<Cover
|
|
||||||
trackId={album.albumId}
|
|
||||||
src={album.thumbnailUrl}
|
|
||||||
size="large"
|
|
||||||
alt={album.title}
|
|
||||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-cover-lg sm:h-41 sm:w-41"
|
|
||||||
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet actions()}
|
|
||||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
|
||||||
{#if isCurrentQueue && player.playing}
|
|
||||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
|
||||||
</CollectionHeader>
|
|
||||||
|
|
||||||
{#if form?.message}
|
|
||||||
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if data.album.description}
|
|
||||||
<p class="mt-6 text-sm text-fg-3">{data.album.description}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<TrackTable index class="mt-6 sm:mt-8">
|
|
||||||
{#each tracks as track, i (track.videoId)}
|
|
||||||
{@const active = player.current.id === track.videoId}
|
|
||||||
<TrackRow
|
|
||||||
{active}
|
|
||||||
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
|
|
||||||
>
|
|
||||||
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
|
||||||
<TrackTitleCell
|
|
||||||
trackId={track.videoId}
|
|
||||||
title={track.title}
|
|
||||||
artist={album.artist}
|
|
||||||
coverSrc={album.thumbnailUrl}
|
|
||||||
explicit={track.isExplicit}
|
|
||||||
{active}
|
|
||||||
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}
|
|
||||||
|
|
||||||
{#if albumMenu}
|
|
||||||
<AlbumContextMenu
|
|
||||||
menu={albumMenu}
|
|
||||||
playlists={data.playlists}
|
|
||||||
onClose={() => (albumMenu = null)}
|
|
||||||
onPlayNext={() => {
|
|
||||||
player.playNext(queue);
|
|
||||||
albumMenu = null;
|
|
||||||
}}
|
|
||||||
onAddToQueue={() => {
|
|
||||||
player.appendToQueue(queue);
|
|
||||||
albumMenu = null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
|
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||||
import {
|
|
||||||
addAlbumToPlaylistAction,
|
|
||||||
addTrackAction,
|
|
||||||
addYouTubeToPlaylistAction,
|
|
||||||
addYoutubeAlbumToPlaylistAction
|
|
||||||
} from '$lib/server/playlistActions';
|
|
||||||
import {
|
import {
|
||||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||||
@@ -53,22 +47,16 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
|
|
||||||
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
|
||||||
|
|
||||||
const albumItems = albumsRes?.data?.items ?? [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
tracks,
|
tracks,
|
||||||
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
|
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
|
||||||
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
|
|
||||||
users: usersRes?.data?.items ?? [],
|
users: usersRes?.data?.items ?? [],
|
||||||
genres: YOUTUBE_FILLER_QUERIES,
|
|
||||||
playlists: playlistsRes?.data?.items ?? []
|
playlists: playlistsRes?.data?.items ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTrack: addTrackAction,
|
addTrack: addTrackAction,
|
||||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
addAlbumToPlaylist: addAlbumToPlaylistAction
|
||||||
addAlbumToPlaylist: addAlbumToPlaylistAction,
|
|
||||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
|
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
|
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
queueItemForTarget,
|
queueItemForTarget,
|
||||||
targetArtist,
|
targetArtist,
|
||||||
targetCoverSrc,
|
|
||||||
targetExplicit,
|
targetExplicit,
|
||||||
targetId,
|
targetId,
|
||||||
targetTitle,
|
targetTitle,
|
||||||
@@ -43,6 +41,8 @@
|
|||||||
user: 'Usuario'
|
user: 'Usuario'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No hay sistema de tags/géneros todavía: esta lista es un placeholder de
|
||||||
|
// cliente y cada tile enlaza a una búsqueda normal por ese término.
|
||||||
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
|
||||||
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
|
||||||
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
|
||||||
@@ -60,23 +60,26 @@
|
|||||||
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
|
||||||
|
query,
|
||||||
|
hue: Math.round((i * 360) / all.length),
|
||||||
|
...info
|
||||||
|
}));
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
||||||
type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
||||||
const SEARCH_GROUP_PREVIEW = 4;
|
const SEARCH_GROUP_PREVIEW = 4;
|
||||||
|
|
||||||
let ytItems = $state<YouTubeSong[]>([]);
|
|
||||||
let ytUnavailable = $state(false);
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
let contextMenu = $state<ContextMenuState | null>(null);
|
||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('Todo');
|
||||||
|
|
||||||
type LocalTrack = NonNullable<(typeof data.tracks.items)[number]['track']>;
|
type LocalTrack = (typeof data.tracks.items)[number]['track'];
|
||||||
|
|
||||||
let localItems = $state<LocalTrack[]>([]);
|
let localItems = $state<LocalTrack[]>([]);
|
||||||
let localPage = $state(1);
|
let localPage = $state(1);
|
||||||
let ytContinuation = $state('');
|
|
||||||
let hasMore = $state(false);
|
let hasMore = $state(false);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
|
|
||||||
@@ -84,63 +87,24 @@
|
|||||||
contextMenu = null;
|
contextMenu = null;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
sfilter = 'Todo';
|
sfilter = 'Todo';
|
||||||
localItems = data.tracks.items.flatMap((item) => (item.track ? [item.track] : []));
|
localItems = data.tracks.items.map((item) => item.track);
|
||||||
localPage = Number(data.tracks.pageNumber);
|
localPage = Number(data.tracks.pageNumber);
|
||||||
ytContinuation = data.tracks.nextYoutubeContinuationToken ?? '';
|
|
||||||
hasMore = data.tracks.hasNextPage;
|
hasMore = data.tracks.hasNextPage;
|
||||||
|
|
||||||
if (data.query) {
|
|
||||||
ytItems = appendUnique(
|
|
||||||
[],
|
|
||||||
data.tracks.items.flatMap((item) =>
|
|
||||||
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
|
|
||||||
),
|
|
||||||
(i) => i.videoId
|
|
||||||
);
|
|
||||||
ytUnavailable = data.tracks.youtubeUnavailable;
|
|
||||||
} else {
|
|
||||||
ytItems = [];
|
|
||||||
ytUnavailable = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const genreTiles = $derived(
|
const songRows = $derived(
|
||||||
data.genres.map((query, i) => ({
|
localItems.map((track) => ({
|
||||||
query,
|
target: { track } satisfies TrackTarget,
|
||||||
hue: Math.round((i * 360) / data.genres.length),
|
seconds: Number(track.duration)
|
||||||
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
|
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const songRows = $derived([
|
|
||||||
...localItems.map((track) => ({
|
|
||||||
target: { kind: 'local' as const, track },
|
|
||||||
seconds: Number(track.duration)
|
|
||||||
})),
|
|
||||||
...ytItems.map((song) => ({
|
|
||||||
target: { kind: 'youtube' as const, song },
|
|
||||||
seconds: Number(song.durationSeconds)
|
|
||||||
}))
|
|
||||||
] satisfies { target: TrackTarget; seconds: number }[]);
|
|
||||||
|
|
||||||
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
|
const items = $derived<TrackTarget[]>(songRows.map((row) => row.target));
|
||||||
|
|
||||||
const albums = $derived(data.albums);
|
const albums = $derived(data.albums);
|
||||||
const youtubeAlbums = $derived(data.youtubeAlbums);
|
|
||||||
const users = $derived(data.users);
|
const users = $derived(data.users);
|
||||||
|
|
||||||
type LocalAlbum = (typeof data.albums)[number];
|
const hasAlbums = $derived(albums.length > 0);
|
||||||
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
|
||||||
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
|
||||||
|
|
||||||
const albumEntries = $derived<AlbumEntry[]>(
|
|
||||||
[
|
|
||||||
...albums.map((album) => ({ kind: 'local' as const, album })),
|
|
||||||
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
|
||||||
].slice(0, EXPLORE_ALBUMS_LIMIT)
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasAlbums = $derived(albumEntries.length > 0);
|
|
||||||
const hasUsers = $derived(users.length > 0);
|
const hasUsers = $derived(users.length > 0);
|
||||||
|
|
||||||
const playlistMatches = $derived(
|
const playlistMatches = $derived(
|
||||||
@@ -151,12 +115,12 @@
|
|||||||
const hasPlaylists = $derived(playlistMatches.length > 0);
|
const hasPlaylists = $derived(playlistMatches.length > 0);
|
||||||
|
|
||||||
const nothingFound = $derived(
|
const nothingFound = $derived(
|
||||||
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchCounts = $derived({
|
const searchCounts = $derived({
|
||||||
Canciones: songRows.length,
|
Canciones: songRows.length,
|
||||||
Álbumes: albumEntries.length,
|
Álbumes: albums.length,
|
||||||
Playlists: playlistMatches.length,
|
Playlists: playlistMatches.length,
|
||||||
Usuarios: users.length
|
Usuarios: users.length
|
||||||
});
|
});
|
||||||
@@ -172,7 +136,7 @@
|
|||||||
|
|
||||||
type TopResult =
|
type TopResult =
|
||||||
| { kind: 'track'; target: TrackTarget }
|
| { kind: 'track'; target: TrackTarget }
|
||||||
| { kind: 'album'; entry: AlbumEntry }
|
| { kind: 'album'; album: (typeof albums)[number] }
|
||||||
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
|
| { kind: 'playlist'; playlist: (typeof playlistMatches)[number] }
|
||||||
| { kind: 'user'; user: (typeof users)[number] };
|
| { kind: 'user'; user: (typeof users)[number] };
|
||||||
|
|
||||||
@@ -185,14 +149,14 @@
|
|||||||
if (userHit) return { kind: 'user', user: userHit };
|
if (userHit) return { kind: 'user', user: userHit };
|
||||||
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
|
const trackHit = items.find((target) => startsWithQuery(targetTitle(target)));
|
||||||
if (trackHit) return { kind: 'track', target: trackHit };
|
if (trackHit) return { kind: 'track', target: trackHit };
|
||||||
const albumHit = albumEntries.find((entry) => startsWithQuery(entry.album.title));
|
const albumHit = albums.find((album) => startsWithQuery(album.title));
|
||||||
if (albumHit) return { kind: 'album', entry: albumHit };
|
if (albumHit) return { kind: 'album', album: albumHit };
|
||||||
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
|
const playlistHit = playlistMatches.find((p) => startsWithQuery(p.name));
|
||||||
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
|
if (playlistHit) return { kind: 'playlist', playlist: playlistHit };
|
||||||
|
|
||||||
if (users.length) return { kind: 'user', user: users[0] };
|
if (users.length) return { kind: 'user', user: users[0] };
|
||||||
if (items.length) return { kind: 'track', target: items[0] };
|
if (items.length) return { kind: 'track', target: items[0] };
|
||||||
if (albumEntries.length) return { kind: 'album', entry: albumEntries[0] };
|
if (albums.length) return { kind: 'album', album: albums[0] };
|
||||||
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
|
if (playlistMatches.length) return { kind: 'playlist', playlist: playlistMatches[0] };
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -216,11 +180,7 @@
|
|||||||
|
|
||||||
const topResultHref = $derived.by(() => {
|
const topResultHref = $derived.by(() => {
|
||||||
if (!topResult || topResult.kind === 'track') return undefined;
|
if (!topResult || topResult.kind === 'track') return undefined;
|
||||||
if (topResult.kind === 'album') {
|
if (topResult.kind === 'album') return `/albums/${topResult.album.id}`;
|
||||||
return topResult.entry.kind === 'local'
|
|
||||||
? `/albums/${topResult.entry.album.id}`
|
|
||||||
: `/albums/external/youtube/${topResult.entry.album.albumId}`;
|
|
||||||
}
|
|
||||||
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
|
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
|
||||||
return `/u/${topResult.user.id}`;
|
return `/u/${topResult.user.id}`;
|
||||||
});
|
});
|
||||||
@@ -233,22 +193,22 @@
|
|||||||
contextMenu = contextMenuStateFor(event, target);
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
|
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||||
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
albumMenu = albumContextMenuStateFor(event, albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function albumPlayNext() {
|
async function albumPlayNext() {
|
||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { kind, albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.playNext(await fetchAlbumQueueItems(kind, albumId));
|
player.playNext(await fetchAlbumQueueItems(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function albumAddToQueue() {
|
async function albumAddToQueue() {
|
||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { kind, albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
|
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToQueue() {
|
function addToQueue() {
|
||||||
@@ -265,25 +225,16 @@
|
|||||||
pageNumber: String(localPage + 1),
|
pageNumber: String(localPage + 1),
|
||||||
pageSize: String(EXPLORE_PAGE_SIZE)
|
pageSize: String(EXPLORE_PAGE_SIZE)
|
||||||
});
|
});
|
||||||
if (data.query) {
|
if (data.query) params.set('name', data.query);
|
||||||
params.set('name', data.query);
|
|
||||||
if (ytContinuation) params.set('youtubeContinuationToken', ytContinuation);
|
|
||||||
}
|
|
||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
const next = (await res.json()) as typeof data.tracks;
|
||||||
localItems = appendUnique(
|
localItems = appendUnique(
|
||||||
localItems,
|
localItems,
|
||||||
next.items.flatMap((item) => (item.track ? [item.track] : [])),
|
next.items.map((item) => item.track),
|
||||||
(track) => track.id
|
(track) => track.id
|
||||||
);
|
);
|
||||||
ytItems = appendUnique(
|
|
||||||
ytItems,
|
|
||||||
next.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
|
|
||||||
(song) => song.videoId
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
localPage = Number(next.pageNumber);
|
||||||
ytContinuation = next.nextYoutubeContinuationToken ?? '';
|
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@@ -346,26 +297,15 @@
|
|||||||
{#if topResult.kind === 'track'}
|
{#if topResult.kind === 'track'}
|
||||||
<Cover
|
<Cover
|
||||||
trackId={targetId(topResult.target)}
|
trackId={targetId(topResult.target)}
|
||||||
src={targetCoverSrc(topResult.target)}
|
|
||||||
size="large"
|
size="large"
|
||||||
alt={targetTitle(topResult.target)}
|
alt={targetTitle(topResult.target)}
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
||||||
/>
|
/>
|
||||||
{:else if topResult.kind === 'album'}
|
{:else if topResult.kind === 'album'}
|
||||||
{#if topResult.entry.kind === 'local'}
|
<PlaylistArt
|
||||||
<PlaylistArt
|
trackIds={topResult.album.coverTrackIds}
|
||||||
trackIds={topResult.entry.album.coverTrackIds}
|
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
/>
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<Cover
|
|
||||||
trackId={topResult.entry.album.albumId}
|
|
||||||
src={topResult.entry.album.thumbnailUrl}
|
|
||||||
size="large"
|
|
||||||
alt={topResult.entry.album.title}
|
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{:else if topResult.kind === 'playlist'}
|
{:else if topResult.kind === 'playlist'}
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={topResult.playlist.id}
|
playlistId={topResult.playlist.id}
|
||||||
@@ -401,7 +341,7 @@
|
|||||||
{topResult.kind === 'track'
|
{topResult.kind === 'track'
|
||||||
? targetTitle(topResult.target)
|
? targetTitle(topResult.target)
|
||||||
: topResult.kind === 'album'
|
: topResult.kind === 'album'
|
||||||
? topResult.entry.album.title
|
? topResult.album.title
|
||||||
: topResult.kind === 'playlist'
|
: topResult.kind === 'playlist'
|
||||||
? topResult.playlist.name
|
? topResult.playlist.name
|
||||||
: topResult.user.name}
|
: topResult.user.name}
|
||||||
@@ -410,10 +350,6 @@
|
|||||||
<div class="truncate text-sm leading-none text-fg-3">
|
<div class="truncate text-sm leading-none text-fg-3">
|
||||||
{targetArtist(topResult.target)}
|
{targetArtist(topResult.target)}
|
||||||
</div>
|
</div>
|
||||||
{:else if topResult.kind === 'album' && topResult.entry.kind === 'youtube' && topResult.entry.album.artist}
|
|
||||||
<div class="truncate text-sm leading-none text-fg-3">
|
|
||||||
{topResult.entry.album.artist}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -517,7 +453,6 @@
|
|||||||
{#snippet art(artClass)}
|
{#snippet art(artClass)}
|
||||||
<Cover
|
<Cover
|
||||||
trackId={targetId(item)}
|
trackId={targetId(item)}
|
||||||
src={targetCoverSrc(item)}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={targetTitle(item)}
|
alt={targetTitle(item)}
|
||||||
class="{artClass} ring-1 ring-line ring-inset"
|
class="{artClass} ring-1 ring-line ring-inset"
|
||||||
@@ -535,7 +470,7 @@
|
|||||||
{#if sfilter === 'Canciones' && hasMore}
|
{#if sfilter === 'Canciones' && hasMore}
|
||||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if !ytUnavailable}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Music}
|
icon={Music}
|
||||||
description="No hay canciones que coincidan con «{data.query}»."
|
description="No hay canciones que coincidan con «{data.query}»."
|
||||||
@@ -556,40 +491,19 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(albumEntries) as entry (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
{#each capped(albums) as album (album.id)}
|
||||||
<li>
|
<li>
|
||||||
{#if entry.kind === 'local'}
|
<SearchResultRow
|
||||||
<SearchResultRow
|
kind="album"
|
||||||
kind="album"
|
title={album.title}
|
||||||
title={entry.album.title}
|
meta="{Number(album.trackCount)} canciones"
|
||||||
meta="{Number(entry.album.trackCount)} canciones"
|
href="/albums/{album.id}"
|
||||||
href="/albums/{entry.album.id}"
|
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
||||||
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
>
|
||||||
>
|
{#snippet art(artClass)}
|
||||||
{#snippet art(artClass)}
|
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} />
|
||||||
<PlaylistArt trackIds={entry.album.coverTrackIds} class={artClass} />
|
{/snippet}
|
||||||
{/snippet}
|
</SearchResultRow>
|
||||||
</SearchResultRow>
|
|
||||||
{:else}
|
|
||||||
<SearchResultRow
|
|
||||||
kind="album"
|
|
||||||
title={entry.album.title}
|
|
||||||
subtitle={entry.album.artist}
|
|
||||||
meta={entry.album.releaseYear ? String(entry.album.releaseYear) : undefined}
|
|
||||||
href="/albums/external/youtube/{entry.album.albumId}"
|
|
||||||
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
|
||||||
>
|
|
||||||
{#snippet art(artClass)}
|
|
||||||
<Cover
|
|
||||||
trackId={entry.album.albumId}
|
|
||||||
src={entry.album.thumbnailUrl}
|
|
||||||
size="small"
|
|
||||||
alt={entry.album.title}
|
|
||||||
class={artClass}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</SearchResultRow>
|
|
||||||
{/if}
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -665,13 +579,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ytUnavailable}
|
|
||||||
<EmptyState
|
|
||||||
icon={Music}
|
|
||||||
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist,
|
artist: t.artist,
|
||||||
source: t.source,
|
|
||||||
coverUrl: t.coverUrl,
|
|
||||||
explicit: t.explicit
|
explicit: t.explicit
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -97,7 +95,6 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
coverSrc={track.coverUrl}
|
|
||||||
explicit={track.explicit}
|
explicit={track.explicit}
|
||||||
{active}
|
{active}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
import { addTrackAction } from '$lib/server/playlistActions';
|
||||||
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||||
@@ -23,6 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTrack: addTrackAction,
|
addTrack: addTrackAction
|
||||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
queueItemForTarget,
|
queueItemForTarget,
|
||||||
targetArtist,
|
targetArtist,
|
||||||
targetCoverSrc,
|
|
||||||
targetId,
|
targetId,
|
||||||
targetTitle
|
targetTitle
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
@@ -107,8 +106,6 @@
|
|||||||
trackId={targetId(targets[i])}
|
trackId={targetId(targets[i])}
|
||||||
title={targetTitle(targets[i])}
|
title={targetTitle(targets[i])}
|
||||||
artist={targetArtist(targets[i])}
|
artist={targetArtist(targets[i])}
|
||||||
coverSrc={targetCoverSrc(targets[i])}
|
|
||||||
explicit={item.isExplicit}
|
|
||||||
{active}
|
{active}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import Modal from '$lib/components/ui/Modal.svelte';
|
import Modal from '$lib/components/ui/Modal.svelte';
|
||||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import {
|
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
||||||
player,
|
|
||||||
isPendingYouTubeTrack,
|
|
||||||
queueIdForTrack,
|
|
||||||
toQueueItems,
|
|
||||||
youTubeThumbnailUrl,
|
|
||||||
type ApiTrackLike
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime, fmtDate } from '$lib/format';
|
import { fmtTime, fmtDate } from '$lib/format';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||||
@@ -41,7 +33,7 @@
|
|||||||
class="mt-6 sm:mt-8"
|
class="mt-6 sm:mt-8"
|
||||||
>
|
>
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === queueIdForTrack(track)}
|
{@const active = player.current.id === track.id}
|
||||||
<TrackRow {active}>
|
<TrackRow {active}>
|
||||||
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
||||||
<TrackTitleCell
|
<TrackTitleCell
|
||||||
@@ -49,21 +41,10 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
|
||||||
? youTubeThumbnailUrl(track.externalId)
|
|
||||||
: undefined}
|
|
||||||
explicit={track.isExplicit}
|
explicit={track.isExplicit}
|
||||||
{active}
|
{active}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
>
|
/>
|
||||||
{#snippet badge()}
|
|
||||||
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
|
|
||||||
<Badge tone="danger">Error</Badge>
|
|
||||||
{:else if isPendingYouTubeTrack(track)}
|
|
||||||
<Badge tone="accent">Descargando</Badge>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
</TrackTitleCell>
|
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, requireUser } from '$lib/server/api';
|
import { createApiClient, requireUser } from '$lib/server/api';
|
||||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
import { addTrackAction } from '$lib/server/playlistActions';
|
||||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
|
||||||
import {
|
import {
|
||||||
HOME_LATEST_PAGE_SIZE,
|
HOME_LATEST_PAGE_SIZE,
|
||||||
HOME_MIXES_LIMIT,
|
HOME_MIXES_LIMIT,
|
||||||
HOME_POPULAR_FILLER_LIMIT,
|
|
||||||
HOME_SHELF_LIMIT,
|
HOME_SHELF_LIMIT,
|
||||||
HOME_SPOTLIGHT_TRACKS_LIMIT
|
HOME_SPOTLIGHT_TRACKS_LIMIT
|
||||||
} from '$lib/config';
|
} from '$lib/config';
|
||||||
@@ -43,9 +41,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
recentlyPlayedPromise
|
recentlyPlayedPromise
|
||||||
]);
|
]);
|
||||||
const playlistItems = playlists.data?.items ?? [];
|
const playlistItems = playlists.data?.items ?? [];
|
||||||
const latestItems = (latest.data?.items ?? []).flatMap((item) =>
|
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
|
||||||
item.track ? [item.track] : []
|
|
||||||
);
|
|
||||||
|
|
||||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||||
const spotlightTracksPromise = spotlightPlaylist
|
const spotlightTracksPromise = spotlightPlaylist
|
||||||
@@ -72,19 +68,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
newReleases: latestItems,
|
newReleases: latestItems,
|
||||||
spotlightPlaylist,
|
spotlightPlaylist,
|
||||||
spotlightTracks: spotlightTracksPromise,
|
spotlightTracks: spotlightTracksPromise,
|
||||||
listeningStats: listeningStatsPromise,
|
listeningStats: listeningStatsPromise
|
||||||
popularFiller: fetchYoutubeFiller(
|
|
||||||
api,
|
|
||||||
locals.accessToken,
|
|
||||||
HOME_POPULAR_FILLER_LIMIT,
|
|
||||||
undefined,
|
|
||||||
user.sub,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addTrack: addTrackAction,
|
addTrack: addTrackAction
|
||||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
queueItemForTarget,
|
queueItemForTarget,
|
||||||
targetArtist,
|
targetArtist,
|
||||||
targetCoverSrc,
|
|
||||||
targetDurationSeconds,
|
|
||||||
targetExplicit,
|
targetExplicit,
|
||||||
targetForQueueItem,
|
targetForQueueItem,
|
||||||
targetId,
|
targetId,
|
||||||
@@ -19,12 +17,11 @@
|
|||||||
targetTitle,
|
targetTitle,
|
||||||
type TrackTarget
|
type TrackTarget
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
|
||||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
import MixTile from '$lib/components/ui/MixTile.svelte';
|
import MixTile from '$lib/components/ui/MixTile.svelte';
|
||||||
import EqBars from '$lib/components/ui/EqBars.svelte';
|
import EqBars from '$lib/components/ui/EqBars.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
@@ -32,36 +29,26 @@
|
|||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
} from '$lib/components/TrackContextMenu.svelte';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
|
||||||
import { appendUnique } from '$lib/collections';
|
|
||||||
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
let contextMenu = $state<ContextMenuState | null>(null);
|
||||||
let popularFillerItems = $state<YouTubeSong[]>([]);
|
|
||||||
let spotlightItems = $state<TrackTarget[]>([]);
|
type SpotlightRow = { target: TrackTarget; seconds: number };
|
||||||
|
let spotlightRows = $state<SpotlightRow[]>([]);
|
||||||
let spotlightTotal = $state(0);
|
let spotlightTotal = $state(0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
popularFillerItems = [];
|
spotlightRows = [];
|
||||||
let cancelled = false;
|
|
||||||
data.popularFiller.then((filler) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
popularFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
spotlightItems = [];
|
|
||||||
spotlightTotal = 0;
|
spotlightTotal = 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
data.spotlightTracks.then(({ items, totalCount }) => {
|
data.spotlightTracks.then(({ items, totalCount }) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
spotlightItems = items.map((track) => ({ kind: 'local' as const, track }));
|
spotlightRows = items.map((track) => ({
|
||||||
|
target: { track },
|
||||||
|
seconds: Number(track.duration)
|
||||||
|
}));
|
||||||
spotlightTotal = totalCount;
|
spotlightTotal = totalCount;
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,10 +59,9 @@
|
|||||||
const mixes = $derived(data.mixes);
|
const mixes = $derived(data.mixes);
|
||||||
const playlists = $derived(data.playlists);
|
const playlists = $derived(data.playlists);
|
||||||
const spotlight = $derived(data.spotlightPlaylist);
|
const spotlight = $derived(data.spotlightPlaylist);
|
||||||
|
const spotlightItems = $derived(spotlightRows.map((row) => row.target));
|
||||||
const spotlightDurationSeconds = $derived(
|
const spotlightDurationSeconds = $derived(
|
||||||
spotlightItems.some((item) => targetDurationSeconds(item) !== undefined)
|
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
|
||||||
? spotlightItems.reduce((sum, item) => sum + (targetDurationSeconds(item) ?? 0), 0)
|
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
|
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
|
||||||
const spotlightVisibilityLabel = $derived(
|
const spotlightVisibilityLabel = $derived(
|
||||||
@@ -86,9 +72,7 @@
|
|||||||
? spotlightVisibilityLabel
|
? spotlightVisibilityLabel
|
||||||
: [
|
: [
|
||||||
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
|
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
|
||||||
spotlightDurationSeconds !== undefined
|
fmtDurationLong(spotlightDurationSeconds),
|
||||||
? fmtDurationLong(spotlightDurationSeconds)
|
|
||||||
: '0 min',
|
|
||||||
spotlightVisibilityLabel
|
spotlightVisibilityLabel
|
||||||
].join(' · ')
|
].join(' · ')
|
||||||
);
|
);
|
||||||
@@ -97,10 +81,7 @@
|
|||||||
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
|
||||||
|
|
||||||
const popularTargets = $derived<TrackTarget[]>(
|
const popularTargets = $derived<TrackTarget[]>(
|
||||||
[
|
recentPool.map(targetForQueueItem).slice(0, HOME_POPULAR_MAX)
|
||||||
...recentPool.map(targetForQueueItem),
|
|
||||||
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
|
||||||
].slice(0, HOME_POPULAR_MAX)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
|
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
|
||||||
@@ -208,42 +189,23 @@
|
|||||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{#each continueItems as item, i (item.id)}
|
{#each continueItems as item, i (item.id)}
|
||||||
{@const isCurrent = player.current.id === item.id}
|
{@const isCurrent = player.current.id === item.id}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
class="animate-enter rounded-[13px] p-2.25 pr-3.5 transition-colors {isCurrent
|
||||||
tabindex="0"
|
|
||||||
onclick={() => playContinue(i)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
||||||
e.preventDefault();
|
|
||||||
playContinue(i);
|
|
||||||
}}
|
|
||||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
|
||||||
class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent
|
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||||
|
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
||||||
>
|
>
|
||||||
<Cover
|
<TrackTitleCell
|
||||||
trackId={item.id}
|
trackId={item.id}
|
||||||
src={item.coverUrl}
|
title={item.title}
|
||||||
size="small"
|
artist={item.artist}
|
||||||
alt={item.title}
|
ownerUserId={item.ownerUserId}
|
||||||
class="h-13 w-13 shrink-0 rounded-[10px]"
|
coverSize="h-13 w-13"
|
||||||
|
active={isCurrent}
|
||||||
|
onClick={() => playContinue(i)}
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div
|
|
||||||
class="truncate text-sm leading-[1.3] font-medium {isCurrent
|
|
||||||
? 'text-accent-soft'
|
|
||||||
: 'text-fg'}"
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
<ArtistLink
|
|
||||||
name={item.artist || '—'}
|
|
||||||
ownerUserId={item.ownerUserId}
|
|
||||||
class="mt-0.75 text-xs text-fg-3"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -301,8 +263,7 @@
|
|||||||
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
|
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
|
||||||
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
|
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
|
||||||
>
|
>
|
||||||
{#each spotlightItems as target, i (targetId(target))}
|
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
||||||
{@const durationSeconds = targetDurationSeconds(target)}
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -333,9 +294,7 @@
|
|||||||
class="mt-0.5 text-xs text-fg-3"
|
class="mt-0.5 text-xs text-fg-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">
|
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||||
{durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||||
@@ -365,27 +324,17 @@
|
|||||||
|
|
||||||
<!-- POPULARES ESTA SEMANA -->
|
<!-- POPULARES ESTA SEMANA -->
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading
|
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
|
||||||
title="Populares esta semana"
|
|
||||||
subtitle="Lo más escuchado entre quienes comparten tu gusto"
|
|
||||||
/>
|
|
||||||
{#if popularTargets.length > 0}
|
{#if popularTargets.length > 0}
|
||||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||||
{#each popularTargets as target, i (targetId(target))}
|
{#each popularTargets as target, i (targetId(target))}
|
||||||
{@const isCurrent = isTargetCurrent(target)}
|
{@const isCurrent = isTargetCurrent(target)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
class="flex items-center gap-3.5 rounded-[11px] p-2 transition-colors {isCurrent
|
||||||
tabindex="0"
|
|
||||||
onclick={() => playPopular(i)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
||||||
e.preventDefault();
|
|
||||||
playPopular(i);
|
|
||||||
}}
|
|
||||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
|
||||||
class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent
|
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
|
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||||
>
|
>
|
||||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||||
{#if isCurrent}
|
{#if isCurrent}
|
||||||
@@ -394,28 +343,16 @@
|
|||||||
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<Cover
|
|
||||||
trackId={targetId(target)}
|
|
||||||
src={targetCoverSrc(target)}
|
|
||||||
size="small"
|
|
||||||
alt={targetTitle(target)}
|
|
||||||
class="h-9.5 w-9.5 shrink-0 rounded-[8px]"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
<TrackTitleCell
|
||||||
{#if targetExplicit(target)}
|
trackId={targetId(target)}
|
||||||
<ExplicitBadge />
|
title={targetTitle(target)}
|
||||||
{/if}
|
artist={targetArtist(target)}
|
||||||
<span
|
|
||||||
class="truncate text-sm font-medium {isCurrent ? 'text-accent-soft' : 'text-fg'}"
|
|
||||||
>
|
|
||||||
{targetTitle(target)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ArtistLink
|
|
||||||
name={targetArtist(target)}
|
|
||||||
ownerUserId={targetOwnerUserId(target)}
|
ownerUserId={targetOwnerUserId(target)}
|
||||||
class="mt-0.5 text-xs text-fg-3"
|
coverSize="h-9.5 w-9.5"
|
||||||
|
explicit={targetExplicit(target)}
|
||||||
|
active={isCurrent}
|
||||||
|
onClick={() => playPopular(i)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
|
||||||
const result = await api.GET('/albums/external/{source}/{externalId}', {
|
|
||||||
params: { path: { source: params.source, externalId: params.externalId } }
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { error } from '@sveltejs/kit';
|
|
||||||
import { loadThumbnail, parseThumbnailUrl } from '$lib/server/thumbnails';
|
|
||||||
import { thumbnailConfig } from '$lib/server/config';
|
|
||||||
import { THUMBNAIL_SIZES } from '$lib/thumbnails';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, request, setHeaders }) => {
|
|
||||||
const source = parseThumbnailUrl(url.searchParams.get('u') ?? '');
|
|
||||||
if (!source) error(400, 'Miniatura no permitida.');
|
|
||||||
|
|
||||||
const requested = Number(url.searchParams.get('size'));
|
|
||||||
const size =
|
|
||||||
Number.isInteger(requested) && requested > 0
|
|
||||||
? Math.min(requested, thumbnailConfig.maxSize)
|
|
||||||
: THUMBNAIL_SIZES.large;
|
|
||||||
|
|
||||||
const image = await loadThumbnail(source, size);
|
|
||||||
if (!image) error(404, 'Miniatura no disponible.');
|
|
||||||
|
|
||||||
setHeaders({
|
|
||||||
'Cache-Control': `public, max-age=${thumbnailConfig.maxAgeSeconds}, immutable`,
|
|
||||||
'Content-Type': image.contentType,
|
|
||||||
ETag: image.etag
|
|
||||||
});
|
|
||||||
|
|
||||||
if (request.headers.get('if-none-match') === image.etag) {
|
|
||||||
return new Response(null, { status: 304 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(image.body);
|
|
||||||
};
|
|
||||||
@@ -7,11 +7,10 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
|||||||
const name = url.searchParams.get('name')?.trim() || undefined;
|
const name = url.searchParams.get('name')?.trim() || undefined;
|
||||||
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
||||||
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
|
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
|
||||||
const youtubeContinuationToken = url.searchParams.get('youtubeContinuationToken') || undefined;
|
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
const result = await api.GET('/tracks', {
|
const result = await api.GET('/tracks', {
|
||||||
params: { query: { name, pageNumber, pageSize, youtubeContinuationToken } }
|
params: { query: { name, pageNumber, pageSize } }
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
|
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
|
||||||
|
|||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
|
||||||
// No hard login gate here: the API itself allows an anonymous request when
|
|
||||||
// AllowAnonymousListening is on, and rejects it with 401 otherwise.
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
|
||||||
const result = await api.GET('/tracks/external/{source}/{externalId}/stream', {
|
|
||||||
params: { path: { source: params.source, externalId: params.externalId } }
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(unwrapOrError(result, 'No se pudo reproducir esta canción.'));
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user