Changes
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
|
||||
interface YouTubeAlbumTracksResponse {
|
||||
album: { artist: string; thumbnailUrl: string };
|
||||
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/youtube/${albumId}/tracks`);
|
||||
if (!res.ok) return [];
|
||||
const detail = (await res.json()) as YouTubeAlbumTracksResponse;
|
||||
return detail.tracks.map((track) => ({
|
||||
id: track.videoId,
|
||||
title: track.title,
|
||||
artist: detail.album.artist,
|
||||
source: 'youtube' as const,
|
||||
coverUrl: detail.album.thumbnailUrl,
|
||||
explicit: track.isExplicit
|
||||
}));
|
||||
}
|
||||
@@ -25,15 +25,19 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: AlbumMenuState;
|
||||
playlists: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
onPlayNext: () => void;
|
||||
onAddToQueue: () => void;
|
||||
}
|
||||
|
||||
let { menu, playlists, onClose }: Props = $props();
|
||||
let { menu, playlists, onClose, onPlayNext, onAddToQueue }: Props = $props();
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
@@ -55,6 +59,22 @@
|
||||
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;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onPlayNext}
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Reproducir a continuación</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAddToQueue}
|
||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
|
||||
>
|
||||
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Añadir a la cola</span>
|
||||
</button>
|
||||
{#if playlists.length > 0}
|
||||
<div class="group/addmenu relative">
|
||||
<div
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
: ''}"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100"
|
||||
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
|
||||
></div>
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
||||
|
||||
@@ -342,15 +342,29 @@ class PlayerState {
|
||||
}
|
||||
|
||||
addToQueue(item: QueueItem) {
|
||||
this.playNext([item]);
|
||||
}
|
||||
|
||||
playNext(items: QueueItem[]) {
|
||||
if (items.length === 0) return;
|
||||
if (this.currentId === null) {
|
||||
this.playQueue([item], 0);
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
const tracks = [...this.tracks];
|
||||
tracks.splice(this.#index() + 1, 0, toTrack(item));
|
||||
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
|
||||
this.tracks = tracks;
|
||||
}
|
||||
|
||||
appendToQueue(items: QueueItem[]) {
|
||||
if (items.length === 0) return;
|
||||
if (this.currentId === null) {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
this.tracks = [...this.tracks, ...items.map(toTrack)];
|
||||
}
|
||||
|
||||
playTrack(id: string | number) {
|
||||
if (this.currentId === id) {
|
||||
this.toggle();
|
||||
|
||||
@@ -174,5 +174,13 @@
|
||||
menu={albumMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
onPlayNext={() => {
|
||||
player.playNext(queue);
|
||||
albumMenu = null;
|
||||
}}
|
||||
onAddToQueue={() => {
|
||||
player.appendToQueue(queue);
|
||||
albumMenu = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
@@ -163,14 +164,24 @@
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
|
||||
function isForeignAlbum(album: LocalAlbum) {
|
||||
return String(album.ownerUserId) !== data.user?.sub;
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
|
||||
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
||||
}
|
||||
|
||||
async function albumPlayNext() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.playNext(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
async function albumAddToQueue() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
@@ -255,9 +266,7 @@
|
||||
href="/albums/{entry.album.id}"
|
||||
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={isForeignAlbum(entry.album)
|
||||
? (e) => openAlbumMenu(e, 'local', entry.album.id)
|
||||
: undefined}
|
||||
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
<PlaylistArt
|
||||
@@ -395,5 +404,7 @@
|
||||
menu={albumMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
onPlayNext={albumPlayNext}
|
||||
onAddToQueue={albumAddToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
addYouTubeToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
import {
|
||||
HOME_ALBUMS_LIMIT,
|
||||
HOME_LATEST_PAGE_SIZE,
|
||||
@@ -62,5 +66,6 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu, {
|
||||
albumContextMenuStateFor,
|
||||
type AlbumMenuState
|
||||
} from '$lib/components/AlbumContextMenu.svelte';
|
||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
||||
@@ -33,6 +38,7 @@
|
||||
let { data } = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
@@ -109,6 +115,24 @@
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||
albumMenu = albumContextMenuStateFor(event, 'local', albumId);
|
||||
}
|
||||
|
||||
async function albumPlayNext() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.playNext(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
async function albumAddToQueue() {
|
||||
if (!albumMenu) return;
|
||||
const { kind, albumId } = albumMenu;
|
||||
albumMenu = null;
|
||||
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
|
||||
}
|
||||
|
||||
function albumCaption(album: (typeof albums)[number]) {
|
||||
return [
|
||||
album.releaseYear ? String(album.releaseYear) : undefined,
|
||||
@@ -195,6 +219,7 @@
|
||||
: `/albums/${album.id}`}
|
||||
class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
||||
>
|
||||
<div class="relative">
|
||||
{#if album.thumbnailUrl}
|
||||
@@ -266,9 +291,13 @@
|
||||
>
|
||||
{playlist.name}
|
||||
</div>
|
||||
{#if playlist.description}
|
||||
<div class="mt-0.5 truncate text-sm text-muted">{playlist.description}</div>
|
||||
{/if}
|
||||
<div class="mt-0.5 truncate text-sm text-muted">
|
||||
{#if playlist.description}
|
||||
{playlist.description}
|
||||
{:else}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
<a
|
||||
@@ -330,7 +359,11 @@
|
||||
</section>
|
||||
{/if}
|
||||
{#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0}
|
||||
<EmptyState icon={Music} title="Todavía no hay música" description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción." />
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
title="Todavía no hay música"
|
||||
description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción."
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -387,3 +420,13 @@
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if albumMenu}
|
||||
<AlbumContextMenu
|
||||
menu={albumMenu}
|
||||
{playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
onPlayNext={albumPlayNext}
|
||||
onAddToQueue={albumAddToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const result = await api.GET('/albums/{albumId}/tracks', {
|
||||
params: {
|
||||
path: { albumId: params.id },
|
||||
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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('/youtube/albums/{albumId}', {
|
||||
params: { path: { albumId: params.albumId } }
|
||||
});
|
||||
|
||||
return json(unwrapOrError(result, 'No se pudo cargar el álbum de YouTube.'));
|
||||
};
|
||||
Reference in New Issue
Block a user