Files
jellytau/src/lib/components/library/PlaylistDetailView.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

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}
&middot; {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>