feat(web-player): add the albums pages
A grid of your albums with a create dialog, and a detail page with the tracklist, inline editing and a picker that lists the songs you uploaded so they can be dropped into the album. Covers reuse the playlist mosaic since an album has no artwork of its own yet.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
albumCoverTrackIds,
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const result = await api.GET('/albums/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
|
||||
});
|
||||
|
||||
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
|
||||
const trackIds = await albumCoverTrackIds(
|
||||
api,
|
||||
albums.items.map((album) => album.id)
|
||||
);
|
||||
|
||||
return { albums, trackIds };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const parsed = parseAlbumForm(await request.formData());
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const { data, error: err } = await api.POST('/albums', { body: parsed.body });
|
||||
|
||||
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
|
||||
|
||||
redirect(303, `/albums/${data.id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Disc from '@lucide/svelte/icons/disc-3';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
|
||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const items = $derived(data.albums.items);
|
||||
const trackIds = $derived(data.trackIds);
|
||||
|
||||
let creating = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Álbumes · Musify</title>
|
||||
<meta name="description" content="Los álbumes que has publicado con tu música subida." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Álbumes" subtitle="Agrupa la música que subes en discos.">
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={() => (creating = true)}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Nuevo álbum
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
{#each items as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
title={album.title}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackCount={Number(album.trackCount)}
|
||||
trackIds={trackIds[album.id] ?? []}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={Disc}
|
||||
title="Todavía no tienes álbumes"
|
||||
description="Crea el primero y añádele las canciones que hayas subido."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (creating = true)}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear álbum
|
||||
</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<Modal open={creating} onClose={() => (creating = false)} title="Nuevo álbum">
|
||||
<AlbumForm
|
||||
action="?/create"
|
||||
titlePlaceholder="Mi primer disco"
|
||||
formMessage={form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
onCancel={() => (creating = false)}
|
||||
/>
|
||||
</Modal>
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError,
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [albumRes, tracksRes, libraryRes] = await Promise.all([
|
||||
api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/albums/{albumId}/tracks', {
|
||||
params: {
|
||||
path: { albumId: params.id },
|
||||
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
|
||||
}
|
||||
}),
|
||||
api.GET('/tracks/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: LIBRARY_PICKER_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inAlbum = new Set(tracks.map((track) => track.id));
|
||||
const library = (libraryRes.data?.items ?? []).filter(
|
||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
||||
);
|
||||
|
||||
return { album, tracks, library, isOwner: Number(album.ownerUserId) === Number(user.sub) };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
|
||||
params: { path: { albumId: params.id, trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
|
||||
if (failure) return failure;
|
||||
return { added: true };
|
||||
},
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const trackId = String((await request.formData()).get('trackId') ?? '');
|
||||
if (!trackId) return fail(400, { message: 'Falta la canción.' });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
|
||||
params: { path: { albumId: params.id, trackId } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
|
||||
if (failure) return failure;
|
||||
return { removed: true };
|
||||
},
|
||||
|
||||
edit: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const parsed = parseAlbumForm(await request.formData());
|
||||
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.PUT('/albums/{albumId}', {
|
||||
params: { path: { albumId: params.id } },
|
||||
body: {
|
||||
newTitle: parsed.body.title,
|
||||
newDescription: parsed.body.description,
|
||||
newReleaseYear: parsed.body.releaseYear
|
||||
}
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
|
||||
if (failure) return failure;
|
||||
return { edited: true };
|
||||
},
|
||||
|
||||
delete: async ({ params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
|
||||
const api = createApiClient({ fetch, accessToken });
|
||||
const result = await api.DELETE('/albums/{albumId}', {
|
||||
params: { path: { albumId: params.id } }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.');
|
||||
if (failure) return failure;
|
||||
redirect(303, '/albums');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import AlbumHeader from './components/AlbumHeader.svelte';
|
||||
import AlbumTrackTable from './components/AlbumTrackTable.svelte';
|
||||
import LibraryPicker from './components/LibraryPicker.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const album = $derived(data.album);
|
||||
const tracks = $derived(data.tracks);
|
||||
const library = $derived(data.library);
|
||||
const isOwner = $derived(data.isOwner);
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
const isCurrentQueue = $derived(
|
||||
tracks.some((track) => queueIdForTrack(track) === player.current.id)
|
||||
);
|
||||
|
||||
function playAll() {
|
||||
if (tracks.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(toQueueItems(tracks), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{album.title} · Musify</title>
|
||||
<meta name="description" content="Álbum {album.title} en Musify." />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<AlbumHeader
|
||||
id={album.id}
|
||||
title={album.title}
|
||||
description={album.description ?? undefined}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackIds={tracks.map((track) => track.id)}
|
||||
playing={isCurrentQueue && player.playing}
|
||||
{isOwner}
|
||||
onPlayAll={playAll}
|
||||
onEdit={() => (editing = true)}
|
||||
onDelete={() => (confirmingDelete = true)}
|
||||
/>
|
||||
|
||||
{#if form?.message}
|
||||
<Alert tone="danger" class="mt-6">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<AlbumTrackTable {tracks} {isOwner} />
|
||||
{/if}
|
||||
|
||||
{#if isOwner && library.length > 0}
|
||||
<LibraryPicker {library} />
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<Modal open={editing} onClose={() => (editing = false)} title="Editar álbum">
|
||||
<AlbumForm
|
||||
action="?/edit"
|
||||
initialTitle={album.title}
|
||||
initialDescription={album.description ?? ''}
|
||||
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Guardar"
|
||||
submittingLabel="Guardando…"
|
||||
onCancel={() => (editing = false)}
|
||||
onSuccess={() => (editing = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
||||
<h2 class="text-lg font-bold tracking-tight text-fg">
|
||||
¿Eliminar «{album.title}»?
|
||||
</h2>
|
||||
<p class="mt-2 text-base text-fg-3">
|
||||
Esta acción no se puede deshacer. Las canciones que contiene seguirán en tu biblioteca.
|
||||
</p>
|
||||
<div class="mt-6 flex items-center justify-end gap-3">
|
||||
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
|
||||
<form method="POST" action="?/delete">
|
||||
<Button type="submit" variant="danger">Eliminar</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
releaseYear?: number;
|
||||
trackIds: (string | number)[];
|
||||
playing: boolean;
|
||||
isOwner: boolean;
|
||||
onPlayAll: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
releaseYear,
|
||||
trackIds,
|
||||
playing,
|
||||
isOwner,
|
||||
onPlayAll,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
[
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}`
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/albums"
|
||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||
>
|
||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</span>
|
||||
Volver a tus álbumes
|
||||
</a>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<PlaylistArt
|
||||
{trackIds}
|
||||
hue={hueFor(id)}
|
||||
size="large"
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
|
||||
<h1
|
||||
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{#if description}
|
||||
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-sm text-muted">{meta}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
{#if isOwner}
|
||||
<Button size="sm" variant="secondary" onclick={onEdit}>
|
||||
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||
Editar
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={onDelete}>
|
||||
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||
Eliminar
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
toQueueItems,
|
||||
type ApiTrackLike
|
||||
} from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import IconButton from '$lib/components/ui/IconButton.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';
|
||||
|
||||
type AlbumTrack = ApiTrackLike & { duration: number | string };
|
||||
|
||||
interface Props {
|
||||
tracks: AlbumTrack[];
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
let { tracks, isOwner }: Props = $props();
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(toQueueItems(tracks), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<TrackTable
|
||||
columns={isOwner ? '32px 1fr 64px 36px' : '32px 1fr 64px'}
|
||||
columnsMobile={isOwner ? '28px 1fr 52px 32px' : '28px 1fr 52px'}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#snippet headers()}
|
||||
<span class="text-center">#</span>
|
||||
<span>Título</span>
|
||||
<span class="hidden text-center sm:block">Duración</span>
|
||||
{#if isOwner}
|
||||
<span></span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
hue={hueFor(track.id)}
|
||||
explicit={track.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
/>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
{#if isOwner}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/removeTrack"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="trackId" value={track.id} />
|
||||
<IconButton type="submit" label="Quitar del álbum" size="sm" revealOnHover>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{/if}
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.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 TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
|
||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
||||
|
||||
interface Props {
|
||||
library: LibraryTrack[];
|
||||
}
|
||||
|
||||
let { library }: Props = $props();
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(toQueueItems(library), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-10 sm:mt-12">
|
||||
<SectionHeading title="Tus canciones subidas" />
|
||||
<TrackTable columns="1fr 64px 36px" columnsMobile="1fr 52px 32px">
|
||||
{#snippet headers()}
|
||||
<span>Título</span>
|
||||
<span class="hidden text-center sm:block">Duración</span>
|
||||
<span></span>
|
||||
{/snippet}
|
||||
{#each library as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
hue={hueFor(track.id)}
|
||||
explicit={track.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.current.id === track.id}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addTrack"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="trackId" value={track.id} />
|
||||
<IconButton type="submit" label="Añadir al álbum" size="sm" revealOnHover>
|
||||
<Plus class="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
</form>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
</div>
|
||||
Reference in New Issue
Block a user