Replace the remaining ~155 untyped invoke() calls across stores, services, components, and routes with the generated commands.* wrappers from $lib/api/bindings, so every IPC call is compile-time-checked against the command signatures. - Register repository_get_subtitle_url and repository_get_video_download_url in specta_builder() and the invoke_handler; regenerate bindings.ts. - Source duplicated wire types (AutoplaySettings, CacheConfig, Session, ConnectivityStatus, audio/video settings, etc.) from bindings. - Fix two bugs surfaced by the typed wrappers: - VideoDownloadButton passed an un-awaited Promise as the stream URL. - setAutoplaySettings omitted the required userId argument. - Update unit tests asserting the old invoke(name, args) shape. - Remove the five param-naming guard tests; the compiler and codegen now enforce what they checked. svelte-check: 0 errors. vitest: green. cargo test --lib: green.
307 lines
10 KiB
Svelte
307 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { commands } from "$lib/api/bindings";
|
|
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 { formatDuration } from "$lib/utils/duration";
|
|
|
|
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.runTimeTicks ?? 0), 0)
|
|
);
|
|
|
|
onMount(() => {
|
|
loadPlaylistItems();
|
|
});
|
|
|
|
async function loadPlaylistItems() {
|
|
loading = true;
|
|
try {
|
|
const repo = auth.getRepository();
|
|
entries = await repo.getPlaylistItems(playlist.id);
|
|
} catch (e) {
|
|
console.error("[PlaylistDetail] 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 repo = auth.getRepository();
|
|
const repositoryHandle = repo.getHandle();
|
|
const trackIds = entries.map(e => e.id);
|
|
await commands.playerPlayTracks(repositoryHandle, {
|
|
trackIds,
|
|
startIndex: 0,
|
|
shuffle: false,
|
|
context: {
|
|
type: "playlist",
|
|
playlistId: playlist.id,
|
|
playlistName: playlist.name,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error("[PlaylistDetail] Failed to play all:", e);
|
|
toast.error("Failed to play playlist");
|
|
}
|
|
}
|
|
|
|
async function handleShufflePlay() {
|
|
if (entries.length === 0) return;
|
|
try {
|
|
const repo = auth.getRepository();
|
|
const repositoryHandle = repo.getHandle();
|
|
const trackIds = entries.map(e => e.id);
|
|
await commands.playerPlayTracks(repositoryHandle, {
|
|
trackIds,
|
|
startIndex: 0,
|
|
shuffle: true,
|
|
context: {
|
|
type: "playlist",
|
|
playlistId: playlist.id,
|
|
playlistName: playlist.name,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error("[PlaylistDetail] 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) {
|
|
console.error("[PlaylistDetail] 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) {
|
|
console.error("[PlaylistDetail] 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) {
|
|
console.error("[PlaylistDetail] 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.primaryImageTag}
|
|
<CachedImage
|
|
itemId={playlist.id}
|
|
imageType="Primary"
|
|
tag={playlist.primaryImageTag}
|
|
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>
|
|
<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>
|