feat: bring over the albums, explore and player work from the worktree
Moves the remaining uncommitted work: the home gets quick mixes and a "Tus álbumes" shelf, saving a YouTube album now goes through addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command, and UserAlbum drops its unused ExternalId. The mixes section keeps its current shape (no refresh action, no badge, hidden when empty) rather than the older version the worktree carried.
This commit is contained in:
Vendored
-60
@@ -73,23 +73,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/albums/youtube/{albumId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Save A YouTube Music Album To Your Library With All Of Its Tracks. */
|
||||
post: operations["SaveYouTubeAlbum"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/albums/{albumId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1042,49 +1025,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SaveYouTubeAlbum: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
albumId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Created */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AlbumApplicationResponse"];
|
||||
};
|
||||
};
|
||||
/** @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;
|
||||
};
|
||||
};
|
||||
};
|
||||
UpdateAlbum: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts" module>
|
||||
const MENU_WIDTH = 240;
|
||||
const SUBMENU_WIDTH = 256;
|
||||
|
||||
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
|
||||
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
|
||||
);
|
||||
|
||||
export function albumContextMenuStateFor(
|
||||
event: MouseEvent,
|
||||
kind: 'local' | 'youtube',
|
||||
albumId: string
|
||||
): AlbumMenuState {
|
||||
event.preventDefault();
|
||||
return {
|
||||
kind,
|
||||
albumId,
|
||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
||||
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
menu: AlbumMenuState;
|
||||
playlists: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { menu, playlists, onClose }: Props = $props();
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-30"
|
||||
role="presentation"
|
||||
onclick={onClose}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
|
||||
style="left:{menu.x}px; top:{menu.y}px;"
|
||||
>
|
||||
{#if playlists.length > 0}
|
||||
<div class="group/addmenu relative">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
|
||||
>
|
||||
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
|
||||
</div>
|
||||
<div
|
||||
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
|
||||
? 'right-full pr-1.5'
|
||||
: 'left-full pl-1.5'}"
|
||||
>
|
||||
<div
|
||||
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
|
||||
>
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
<form
|
||||
method="POST"
|
||||
action={menu.kind === 'youtube'
|
||||
? '?/addYoutubeAlbumToPlaylist'
|
||||
: '?/addAlbumToPlaylist'}
|
||||
use:enhance={() =>
|
||||
({ update }) => {
|
||||
onClose();
|
||||
return update({ reset: false, invalidateAll: false });
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||
<input type="hidden" name="albumId" value={menu.albumId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={[]}
|
||||
hue={hueFor(playlist.id)}
|
||||
size="small"
|
||||
class="h-9 w-9 shrink-0 rounded-control"
|
||||
/>
|
||||
<span class="truncate text-fg-2">{playlist.name}</span>
|
||||
</button>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@
|
||||
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
|
||||
<a href="/" class="px-3 text-xl font-medium tracking-wide">Musify</a>
|
||||
|
||||
<div class="relative flex flex-col gap-7" bind:this={navBox}>
|
||||
<div
|
||||
@@ -132,7 +132,7 @@
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate leading-[1.35]">{user.name}</span>
|
||||
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
typeLabel?: string;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: 'small' | 'medium' | 'large';
|
||||
coverShape?: 'square' | 'round';
|
||||
hue: number;
|
||||
explicit?: boolean;
|
||||
active: boolean;
|
||||
@@ -28,6 +29,7 @@
|
||||
typeLabel,
|
||||
coverSrc,
|
||||
coverSize = 'large',
|
||||
coverShape = 'square',
|
||||
hue,
|
||||
explicit = false,
|
||||
active,
|
||||
@@ -58,7 +60,9 @@
|
||||
{hue}
|
||||
size={coverSize}
|
||||
alt={title}
|
||||
class="aspect-square w-full rounded-art shadow-art"
|
||||
class="aspect-square w-full shadow-art {coverShape === 'round'
|
||||
? 'rounded-art-round'
|
||||
: 'rounded-art'}"
|
||||
>
|
||||
<PlayPauseOverlay {active} {playing} />
|
||||
</Cover>
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
</script>
|
||||
|
||||
{#if player.error}
|
||||
<div
|
||||
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
|
||||
>
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
|
||||
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
trackIds?: (string | number)[];
|
||||
coverSrc?: string | null;
|
||||
index?: number;
|
||||
onContextMenu?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +27,8 @@
|
||||
trackCount,
|
||||
trackIds = [],
|
||||
coverSrc,
|
||||
index = 0
|
||||
index = 0,
|
||||
onContextMenu
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
@@ -47,6 +49,7 @@
|
||||
href={href ?? `/albums/${id}`}
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
oncontextmenu={onContextMenu}
|
||||
>
|
||||
{#if coverSrc}
|
||||
<Cover
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,7 +21,8 @@
|
||||
src,
|
||||
alt = '',
|
||||
class: klass = '',
|
||||
children
|
||||
children,
|
||||
oncontextmenu
|
||||
}: Props = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
@@ -40,6 +42,8 @@
|
||||
<div
|
||||
class="relative overflow-hidden {klass}"
|
||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
||||
role="presentation"
|
||||
{oncontextmenu}
|
||||
>
|
||||
{#if !failed}
|
||||
<img
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="relative w-full">
|
||||
{#if Icon}
|
||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
|
||||
<Icon class="h-4 w-4" />
|
||||
<Icon class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
<input
|
||||
@@ -52,7 +52,7 @@
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
bind:value
|
||||
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
|
||||
class="w-full rounded-control bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
|
||||
? 'pr-4 pl-11'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, class: klass = '' }: Props = $props();
|
||||
let { title, class: klass = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
|
||||
{#if actions}
|
||||
<div class="mb-4 flex items-center justify-between gap-4 sm:mb-6 {klass}">
|
||||
<h2 class="text-xl font-bold text-fg sm:text-2xl">{title}</h2>
|
||||
{@render actions()}
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
|
||||
{/if}
|
||||
|
||||
@@ -6,7 +6,9 @@ export const ALBUMS_PAGE_SIZE = 50;
|
||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 18;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUM_SLOTS = 10;
|
||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||
export const HOME_ALBUMS_PAGE_SIZE = 12;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
|
||||
@@ -75,6 +75,15 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
async function readErrorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.json()) as { message?: string };
|
||||
return body?.message || `Error ${res.status}`;
|
||||
} catch {
|
||||
return `Error ${res.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 15;
|
||||
|
||||
const EMPTY: PlayerTrack = {
|
||||
@@ -286,13 +295,16 @@ class PlayerState {
|
||||
if (token !== this.#loadToken) return;
|
||||
this.loading = false;
|
||||
this.playing = false;
|
||||
this.error = 'No se pudo reproducir la pista.';
|
||||
this.error =
|
||||
exception instanceof Error && exception.message
|
||||
? exception.message
|
||||
: 'No se pudo reproducir la pista.';
|
||||
}
|
||||
}
|
||||
|
||||
async #resolveLocalSrc(id: string): Promise<string> {
|
||||
const res = await fetch(`/api/tracks/${id}/stream`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||
const { manifestUrl, ticket } = (await res.json()) as {
|
||||
manifestUrl: string;
|
||||
ticket: string;
|
||||
@@ -302,7 +314,7 @@ class PlayerState {
|
||||
|
||||
async #resolveYouTubeSrc(videoId: string): Promise<string> {
|
||||
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||
const { mode, streamUrl, ticket } = (await res.json()) as {
|
||||
mode: string;
|
||||
streamUrl: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
@@ -59,3 +60,79 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc
|
||||
|
||||
return { added: true, videoId, playlistId };
|
||||
}
|
||||
|
||||
export async function addAlbumToPlaylistAction({ 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: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
|
||||
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
if (tracksErr || !tracks) {
|
||||
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
for (const track of tracks.items) {
|
||||
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
|
||||
params: { path: { playlistId, 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('/youtube/albums/{albumId}', {
|
||||
params: { path: { 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}/youtube-tracks', {
|
||||
params: { path: { playlistId } },
|
||||
body: {
|
||||
videoId: track.videoId,
|
||||
title: track.title,
|
||||
artist: detail.album.artist,
|
||||
durationSeconds: Number(track.durationSeconds),
|
||||
thumbnailUrl: detail.album.thumbnailUrl
|
||||
}
|
||||
});
|
||||
if (!err) added++;
|
||||
}
|
||||
|
||||
return { addedAlbum: true, albumId, playlistId, added };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user