feat(playlists): single playlist view
This commit is contained in:
@@ -86,8 +86,13 @@
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{#each library.playlists as playlist}
|
||||
{@const playlistLocation = `/playlists/${playlist.id}`}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton class="cursor-pointer">
|
||||
<Sidebar.MenuSubButton
|
||||
class="cursor-pointer"
|
||||
isActive={$page.url.pathname === playlistLocation}
|
||||
href={playlistLocation}
|
||||
>
|
||||
{playlist.name}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
|
||||
48
src/lib/components/groove/PlaylistCover.svelte
Normal file
48
src/lib/components/groove/PlaylistCover.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { getCoverUrl } from '$lib/covers';
|
||||
import type { Track } from '$lib/proto/library';
|
||||
|
||||
interface Props {
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
let { tracks }: Props = $props();
|
||||
|
||||
let coverImages = $derived.by(() => {
|
||||
if (tracks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (tracks.length >= 4) {
|
||||
return tracks.slice(0, 4).map((t) => getCoverUrl(t.hash));
|
||||
}
|
||||
|
||||
if (tracks.length === 1) {
|
||||
return [getCoverUrl(tracks[0].hash)];
|
||||
}
|
||||
|
||||
if (tracks.length === 2) {
|
||||
const x = getCoverUrl(tracks[0].hash);
|
||||
const y = getCoverUrl(tracks[1].hash);
|
||||
return [x, y, y, x];
|
||||
}
|
||||
|
||||
let _covers: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (_covers.length < 4) {
|
||||
_covers.push(getCoverUrl(tracks[i].hash));
|
||||
i = (i + 1) % tracks.length;
|
||||
}
|
||||
|
||||
return _covers;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative grid" class:grid-cols-2={coverImages.length > 1}>
|
||||
{#each coverImages as cover}
|
||||
<img class="aspect-square" src={cover} alt="" />
|
||||
{:else}
|
||||
<div class="w-full aspect-square animate-pulse bg-secondary"></div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Track } from '$lib/proto/library';
|
||||
import dayjs from 'dayjs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Trash2 from 'virtual:icons/lucide/trash-2';
|
||||
import { enhance } from '$app/forms';
|
||||
import Play from 'virtual:icons/lucide/play';
|
||||
import type { SubmitFunction } from '../../../routes/playlists/[id]/$types';
|
||||
import { getCoverUrl } from '$lib/covers';
|
||||
import { getLibraryState } from '$lib/library.svelte';
|
||||
import { getPlayerState } from '$lib/player.svelte';
|
||||
import PlaylistCover from './PlaylistCover.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
@@ -24,36 +27,6 @@
|
||||
const library = getLibraryState();
|
||||
const player = getPlayerState();
|
||||
|
||||
let coverImages = $derived.by(() => {
|
||||
if (tracks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (tracks.length >= 4) {
|
||||
return tracks.slice(0, 4).map((t) => getCoverUrl(t.hash));
|
||||
}
|
||||
|
||||
if (tracks.length === 1) {
|
||||
return [getCoverUrl(tracks[0].hash)];
|
||||
}
|
||||
|
||||
if (tracks.length === 2) {
|
||||
const x = getCoverUrl(tracks[0].hash);
|
||||
const y = getCoverUrl(tracks[1].hash);
|
||||
return [x, y, y, x];
|
||||
}
|
||||
|
||||
let _covers: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (_covers.length < 4) {
|
||||
_covers.push(getCoverUrl(tracks[i].hash));
|
||||
i = (i + 1) % tracks.length;
|
||||
}
|
||||
|
||||
return _covers;
|
||||
});
|
||||
|
||||
const playPlaylist: SubmitFunction = async () => {
|
||||
return async ({ update, result }) => {
|
||||
await update({
|
||||
@@ -84,20 +57,21 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="overflow-hidden rounded-md border">
|
||||
<div class="relative grid" class:grid-cols-2={coverImages.length > 1}>
|
||||
{#each coverImages as cover}
|
||||
<img class="aspect-square" src={cover} alt="" />
|
||||
{:else}
|
||||
<div class="w-full aspect-square animate-pulse bg-secondary"></div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="button"
|
||||
class="overflow-hidden rounded-md border"
|
||||
onclick={() => goto(`/playlists/${id}`)}
|
||||
>
|
||||
<PlaylistCover {tracks} />
|
||||
|
||||
<div class="space-y-2 p-4">
|
||||
<div class="relative space-y-2">
|
||||
<h3 class="font-medium leading-none">{name}</h3>
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{dayjs(totalDuration, 'milliseconds').format('mm:ss')}
|
||||
{dayjs.duration(totalDuration, 'milliseconds').format('mm:ss')}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
|
||||
interface Props {
|
||||
track: Track;
|
||||
currentlyPlaying?: boolean;
|
||||
oncontextmenu?: (event: MouseEvent, hash: string) => any;
|
||||
}
|
||||
|
||||
let { track, currentlyPlaying = false, oncontextmenu }: Props = $props();
|
||||
let { track, oncontextmenu }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3" data-track-hash={track.hash}>
|
||||
|
||||
28
src/lib/components/ui/table/index.ts
Normal file
28
src/lib/components/ui/table/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Root from "./table.svelte";
|
||||
import Body from "./table-body.svelte";
|
||||
import Caption from "./table-caption.svelte";
|
||||
import Cell from "./table-cell.svelte";
|
||||
import Footer from "./table-footer.svelte";
|
||||
import Head from "./table-head.svelte";
|
||||
import Header from "./table-header.svelte";
|
||||
import Row from "./table-row.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow,
|
||||
};
|
||||
16
src/lib/components/ui/table/table-body.svelte
Normal file
16
src/lib/components/ui/table/table-body.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody bind:this={ref} class={cn("[&_tr:last-child]:border-0", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
16
src/lib/components/ui/table/table-caption.svelte
Normal file
16
src/lib/components/ui/table/table-caption.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption bind:this={ref} class={cn("text-muted-foreground mt-4 text-sm", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
23
src/lib/components/ui/table/table-cell.svelte
Normal file
23
src/lib/components/ui/table/table-cell.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTdAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
16
src/lib/components/ui/table/table-footer.svelte
Normal file
16
src/lib/components/ui/table/table-footer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot bind:this={ref} class={cn("bg-muted/50 font-medium", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
23
src/lib/components/ui/table/table-head.svelte
Normal file
23
src/lib/components/ui/table/table-head.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLThAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
16
src/lib/components/ui/table/table-header.svelte
Normal file
16
src/lib/components/ui/table/table-header.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead bind:this={ref} class={cn("[&_tr]:border-b", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
23
src/lib/components/ui/table/table-row.svelte
Normal file
23
src/lib/components/ui/table/table-row.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
18
src/lib/components/ui/table/table.svelte
Normal file
18
src/lib/components/ui/table/table.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from "svelte/elements";
|
||||
import type { WithElementRef } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table bind:this={ref} class={cn("w-full caption-bottom text-sm", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@ import type { PlayerStatus } from './proto/player';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { PlayerClient } from './proto/player.client';
|
||||
import { protoTransport } from '../hooks.client';
|
||||
import { deserialize } from '$app/forms';
|
||||
|
||||
class PlayerState {
|
||||
volume = $state(0.0);
|
||||
@@ -27,6 +28,50 @@ class PlayerState {
|
||||
stream.responses.onMessage((status) => this.applyStatus(status));
|
||||
}
|
||||
|
||||
async playTrack(hash: string, fetch: typeof globalThis.fetch) {
|
||||
const response = await fetch(`/tracks/${hash}?/play`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-sveltekit-action': 'true'
|
||||
},
|
||||
body: new FormData()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = deserialize(await response.text());
|
||||
|
||||
if (result.type === 'success' && result.data) {
|
||||
const track = result.data.track as Track | null;
|
||||
const position = result.data.position as bigint;
|
||||
|
||||
this.currentlyPlaying = track;
|
||||
this.progress = position;
|
||||
}
|
||||
}
|
||||
|
||||
async playPlaylist(id: number, fetch: typeof globalThis.fetch) {
|
||||
const response = await fetch(`/playlists/${id}?/play`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-sveltekit-action': 'true'
|
||||
},
|
||||
body: new FormData()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = deserialize(await response.text());
|
||||
|
||||
if (result.type === 'success' && result.data) {
|
||||
this.applyStatus(result.data as unknown as PlayerStatus);
|
||||
}
|
||||
}
|
||||
|
||||
applyStatus(status: PlayerStatus) {
|
||||
if (!this.adjustingVolume) {
|
||||
this.volume = status.volume;
|
||||
|
||||
78
src/routes/playlists/[id]/+page.svelte
Normal file
78
src/routes/playlists/[id]/+page.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { getLibraryState } from '$lib/library.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import PlaylistCover from '$lib/components/groove/PlaylistCover.svelte';
|
||||
import dayjs from 'dayjs';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { getCoverUrl } from '$lib/covers';
|
||||
import Play from 'virtual:icons/lucide/play';
|
||||
import { getPlayerState } from '$lib/player.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
dayjs.extend(duration);
|
||||
|
||||
const library = getLibraryState();
|
||||
const player = getPlayerState();
|
||||
|
||||
let playlist = $derived(library.playlists.find((p) => p.id === Number($page.params.id)));
|
||||
|
||||
let totalDuration = $derived<number>(
|
||||
playlist?.tracks.reduce((acc, track) => acc + Number(track.duration), 0) ?? 0
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if playlist}
|
||||
<div class="flex w-full flex-col gap-8">
|
||||
<div class="flex flex-row items-center justify-center gap-4">
|
||||
<div class="relative w-56 overflow-hidden rounded-lg shadow-2xl shadow-primary/50">
|
||||
<PlaylistCover tracks={playlist.tracks} />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h4 class="text-xl font-semibold tracking-tight">{playlist.name}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{playlist.tracks.length} track{playlist.tracks.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{dayjs.duration(totalDuration, 'milliseconds').format('HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-4">
|
||||
<div class="mb-2">
|
||||
<Button size="icon" onclick={() => player.playPlaylist(playlist.id, fetch)}><Play /></Button
|
||||
>
|
||||
</div>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-0">Position</Table.Head>
|
||||
<Table.Head class="w-0">Track</Table.Head>
|
||||
<Table.Head class="w-0"></Table.Head>
|
||||
<Table.Head class="w-0">Artist</Table.Head>
|
||||
<Table.Head class="text-right">Duration</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<!-- formaction="/tracks/{track.hash}?/play" -->
|
||||
{#each playlist.tracks as track, i}
|
||||
<Table.Row class="select-none" ondblclick={() => player.playTrack(track.hash, fetch)}>
|
||||
<Table.Cell class="text-center">{i + 1}</Table.Cell>
|
||||
<Table.Cell class="w-0">
|
||||
<img class="overflow-hidden rounded-md" src={getCoverUrl(track.hash)} alt="" />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-nowrap">{track.name}</Table.Cell>
|
||||
<Table.Cell class="w-fit text-nowrap">{track.artistName}</Table.Cell>
|
||||
<Table.Cell class="text-right"
|
||||
>{dayjs
|
||||
.duration(Number(track.duration), 'milliseconds')
|
||||
.format('mm:ss')}</Table.Cell
|
||||
>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
404
|
||||
{/if}
|
||||
Reference in New Issue
Block a user