Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
326 lines
10 KiB
Svelte
326 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { playerController } from "$lib/player";
|
|
import type { MediaItem, PlaylistEntry } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { toast } from "$lib/stores/toast";
|
|
import TrackList from "./TrackList.svelte";
|
|
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
|
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
|
import { favoriteOverrides } from "$lib/stores/favorites";
|
|
import { formatDuration } from "$lib/utils/duration";
|
|
import { createLogger } from "$lib/utils/logger";
|
|
|
|
const log = createLogger("PlaylistDetail");
|
|
|
|
interface Props {
|
|
playlist: MediaItem;
|
|
}
|
|
|
|
let { playlist }: Props = $props();
|
|
|
|
let entries = $state<PlaylistEntry[]>([]);
|
|
let loading = $state(true);
|
|
let editingName = $state(false);
|
|
let editName = $state("");
|
|
let showDeleteConfirm = $state(false);
|
|
|
|
// Extract MediaItem[] from PlaylistEntry[] for TrackList
|
|
const tracks = $derived(entries.map((e) => ({ ...e }) as MediaItem));
|
|
|
|
const totalDuration = $derived(entries.reduce((sum, e) => sum + (e.durationMs ?? 0), 0));
|
|
|
|
onMount(() => {
|
|
loadPlaylistItems();
|
|
});
|
|
|
|
async function loadPlaylistItems() {
|
|
loading = true;
|
|
try {
|
|
const repo = auth.getRepository();
|
|
entries = await repo.getPlaylistItems(playlist.id);
|
|
} catch (e) {
|
|
log.error("Failed to load items:", e);
|
|
toast.error("Failed to load playlist items");
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handlePlayAll() {
|
|
if (entries.length === 0) return;
|
|
try {
|
|
const trackIds = entries.map((e) => e.id);
|
|
await playerController.playTracks({
|
|
trackIds,
|
|
startIndex: 0,
|
|
shuffle: false,
|
|
context: {
|
|
type: "playlist",
|
|
playlistId: playlist.id,
|
|
playlistName: playlist.name,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
log.error("Failed to play all:", e);
|
|
toast.error("Failed to play playlist");
|
|
}
|
|
}
|
|
|
|
async function handleShufflePlay() {
|
|
if (entries.length === 0) return;
|
|
try {
|
|
const trackIds = entries.map((e) => e.id);
|
|
await playerController.playTracks({
|
|
trackIds,
|
|
startIndex: 0,
|
|
shuffle: true,
|
|
context: {
|
|
type: "playlist",
|
|
playlistId: playlist.id,
|
|
playlistName: playlist.name,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
log.error("Failed to shuffle play:", e);
|
|
toast.error("Failed to shuffle playlist");
|
|
}
|
|
}
|
|
|
|
async function handleRename() {
|
|
const trimmed = editName.trim();
|
|
if (!trimmed || trimmed === playlist.name) {
|
|
editingName = false;
|
|
editName = playlist.name;
|
|
return;
|
|
}
|
|
try {
|
|
const repo = auth.getRepository();
|
|
await repo.renamePlaylist(playlist.id, trimmed);
|
|
playlist.name = trimmed;
|
|
toast.success("Playlist renamed");
|
|
} catch (e) {
|
|
log.error("Failed to rename:", e);
|
|
toast.error("Failed to rename playlist");
|
|
editName = playlist.name;
|
|
} finally {
|
|
editingName = false;
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
try {
|
|
const repo = auth.getRepository();
|
|
await repo.deletePlaylist(playlist.id);
|
|
toast.success("Playlist deleted");
|
|
goto("/library");
|
|
} catch (e) {
|
|
log.error("Failed to delete:", e);
|
|
toast.error("Failed to delete playlist");
|
|
} finally {
|
|
showDeleteConfirm = false;
|
|
}
|
|
}
|
|
|
|
async function handleRemoveTrack(entry: PlaylistEntry) {
|
|
try {
|
|
const repo = auth.getRepository();
|
|
await repo.removeFromPlaylist(playlist.id, [entry.playlistItemId]);
|
|
entries = entries.filter((e) => e.playlistItemId !== entry.playlistItemId);
|
|
toast.success("Track removed");
|
|
} catch (e) {
|
|
log.error("Failed to remove track:", e);
|
|
toast.error("Failed to remove track");
|
|
}
|
|
}
|
|
|
|
function handleRenameKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Enter") handleRename();
|
|
if (e.key === "Escape") {
|
|
editingName = false;
|
|
editName = playlist.name;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex gap-6 pt-4">
|
|
<!-- Playlist artwork -->
|
|
<div class="flex-shrink-0 w-48">
|
|
{#if playlist.imageId}
|
|
<CachedImage
|
|
itemId={playlist.id}
|
|
imageType="Primary"
|
|
tag={playlist.imageId}
|
|
maxWidth={400}
|
|
alt={playlist.name}
|
|
class="w-full rounded-lg shadow-lg"
|
|
/>
|
|
{:else}
|
|
<div
|
|
class="w-full aspect-square bg-[var(--color-surface)] rounded-lg flex items-center justify-center"
|
|
>
|
|
<svg class="w-16 h-16 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M15 6H3v2h12V6zm0 4H3v2h12v-2zM3 16h8v-2H3v2zM17 6v8.18c-.31-.11-.65-.18-1-.18-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3V8h3V6h-5z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1 space-y-4">
|
|
<div>
|
|
{#if editingName}
|
|
<input
|
|
type="text"
|
|
bind:value={editName}
|
|
onkeydown={handleRenameKeydown}
|
|
onblur={handleRename}
|
|
class="text-3xl font-bold bg-transparent border-b-2 border-[var(--color-jellyfin)] text-white outline-none w-full"
|
|
/>
|
|
{:else}
|
|
<button
|
|
class="text-3xl font-bold text-white cursor-pointer hover:text-[var(--color-jellyfin)] transition-colors bg-transparent border-none p-0 text-left"
|
|
onclick={() => {
|
|
editingName = true;
|
|
editName = playlist.name;
|
|
}}
|
|
title="Click to rename"
|
|
>
|
|
{playlist.name}
|
|
</button>
|
|
{/if}
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
{entries.length} track{entries.length !== 1 ? "s" : ""}
|
|
{#if totalDuration > 0}
|
|
· {formatDuration(totalDuration)}
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex gap-3 flex-wrap">
|
|
<button
|
|
onclick={handlePlayAll}
|
|
disabled={entries.length === 0}
|
|
class="px-6 py-2 bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin-dark)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
Play All
|
|
</button>
|
|
<button
|
|
onclick={handleShufflePlay}
|
|
disabled={entries.length === 0}
|
|
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:cursor-not-allowed rounded-lg font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"
|
|
/>
|
|
</svg>
|
|
Shuffle
|
|
</button>
|
|
<!-- TRACES: UR-068 | DR-119 -->
|
|
<FavoriteButton
|
|
itemId={playlist.id}
|
|
isFavorite={$favoriteOverrides.get(playlist.id) ?? false}
|
|
size="lg"
|
|
className="self-center"
|
|
/>
|
|
<button
|
|
onclick={() => (showDeleteConfirm = true)}
|
|
class="px-4 py-2 bg-[var(--color-surface)] hover:bg-red-900/50 text-red-400 hover:text-red-300 rounded-lg font-medium flex items-center gap-2 transition-colors"
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"
|
|
/>
|
|
</svg>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
|
|
{#if playlist.overview}
|
|
<p class="text-gray-300 leading-relaxed max-w-2xl">{playlist.overview}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tracks -->
|
|
<div class="space-y-4">
|
|
<h2 class="text-xl font-semibold text-white">Tracks</h2>
|
|
<TrackList
|
|
{tracks}
|
|
{loading}
|
|
showArtist={true}
|
|
showAlbum={true}
|
|
context={{ type: "playlist", playlistId: playlist.id, playlistName: playlist.name }}
|
|
/>
|
|
|
|
{#if !loading && entries.length > 0}
|
|
<div class="space-y-1 mt-4">
|
|
{#each entries as entry, i (entry.playlistItemId)}
|
|
<div class="flex items-center justify-end px-4 -mt-1">
|
|
<button
|
|
onclick={() => handleRemoveTrack(entry)}
|
|
class="text-gray-500 hover:text-red-400 p-1 transition-colors"
|
|
title="Remove from playlist"
|
|
>
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M19 13H5v-2h14v2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
{#if showDeleteConfirm}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
|
|
onclick={() => (showDeleteConfirm = false)}
|
|
onkeydown={(e) => {
|
|
if (e.key === "Escape") showDeleteConfirm = false;
|
|
}}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
tabindex="-1"
|
|
>
|
|
<div
|
|
class="bg-[var(--color-surface)] rounded-xl p-6 max-w-sm mx-4 space-y-4"
|
|
onclick={(e) => e.stopPropagation()}
|
|
onkeydown={(e) => e.stopPropagation()}
|
|
role="presentation"
|
|
>
|
|
<h3 class="text-lg font-semibold text-white">Delete Playlist?</h3>
|
|
<p class="text-gray-300">
|
|
Are you sure you want to delete "{playlist.name}"? This action cannot be undone.
|
|
</p>
|
|
<div class="flex gap-3 justify-end">
|
|
<button
|
|
onclick={() => (showDeleteConfirm = false)}
|
|
class="px-4 py-2 bg-[var(--color-surface-hover)] hover:bg-gray-600 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onclick={handleDelete}
|
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|