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.
234 lines
7.6 KiB
Svelte
234 lines
7.6 KiB
Svelte
<script lang="ts">
|
|
import { commands } from "$lib/api/bindings";
|
|
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
|
import type { MediaItem } from "$lib/api/types";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { queue } from "$lib/stores/queue";
|
|
import CachedImage from "../common/CachedImage.svelte";
|
|
|
|
interface Props {
|
|
items: MediaItem[];
|
|
currentIndex?: number | null;
|
|
onItemClick?: (index: number) => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
let {
|
|
items,
|
|
currentIndex = null,
|
|
onItemClick,
|
|
onClose,
|
|
}: Props = $props();
|
|
|
|
// Add unique IDs for dnd-zone (required)
|
|
interface DndItem extends MediaItem {
|
|
dndId: string;
|
|
}
|
|
|
|
let dndItems = $derived<DndItem[]>(
|
|
items.map((item, index) => ({
|
|
...item,
|
|
dndId: `${item.id}-${index}`,
|
|
}))
|
|
);
|
|
|
|
let dragDisabled = $state(true);
|
|
const flipDurationMs = 200;
|
|
|
|
function formatDuration(ticks?: number | null): string {
|
|
if (!ticks) return "";
|
|
const seconds = Math.floor(ticks / 10000000);
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function handleConsider(e: CustomEvent<{ items: DndItem[]; info: { source: string; trigger: string } }>) {
|
|
const { items: newItems, info } = e.detail;
|
|
// Update local state during drag
|
|
if (info.source === SOURCES.KEYBOARD && info.trigger === TRIGGERS.DRAG_STOPPED) {
|
|
dragDisabled = true;
|
|
}
|
|
}
|
|
|
|
async function handleFinalize(e: CustomEvent<{ items: DndItem[]; info: { source: string } }>) {
|
|
const { items: newItems, info } = e.detail;
|
|
|
|
// Find the moved item by comparing old and new positions
|
|
const oldIds = dndItems.map(i => i.dndId);
|
|
const newIds = newItems.map(i => i.dndId);
|
|
|
|
// Find indices that changed
|
|
let fromIndex = -1;
|
|
let toIndex = -1;
|
|
|
|
for (let i = 0; i < oldIds.length; i++) {
|
|
if (oldIds[i] !== newIds[i]) {
|
|
if (fromIndex === -1) {
|
|
// Find where the item at this position came from
|
|
fromIndex = oldIds.indexOf(newIds[i]);
|
|
toIndex = i;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
try {
|
|
// Optimistic update
|
|
queue.moveInQueue(fromIndex, toIndex);
|
|
|
|
// Sync with backend
|
|
await commands.playerMoveInQueue(fromIndex, toIndex);
|
|
} catch (e) {
|
|
console.error("Failed to move queue item:", e);
|
|
// The store already updated optimistically, refresh if needed
|
|
}
|
|
}
|
|
|
|
if (info.source === SOURCES.POINTER) {
|
|
dragDisabled = true;
|
|
}
|
|
}
|
|
|
|
function startDrag(e: Event) {
|
|
e.preventDefault();
|
|
dragDisabled = false;
|
|
}
|
|
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if ((e.key === "Enter" || e.key === " ") && dragDisabled) {
|
|
dragDisabled = false;
|
|
}
|
|
}
|
|
|
|
async function handleRemove(e: Event, index: number) {
|
|
e.stopPropagation();
|
|
try {
|
|
queue.removeFromQueue(index);
|
|
await commands.playerRemoveFromQueue(index);
|
|
} catch (err) {
|
|
console.error("Failed to remove from queue:", err);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg overflow-hidden">
|
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
|
<h2 class="text-lg font-semibold text-white">Queue ({items.length})</h2>
|
|
<button
|
|
onclick={onClose}
|
|
class="p-1 rounded hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
|
aria-label="Close queue"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="max-h-96 overflow-y-auto">
|
|
{#if items.length === 0}
|
|
<div class="p-8 text-center text-gray-400">
|
|
<p>Queue is empty</p>
|
|
</div>
|
|
{:else}
|
|
<ul
|
|
use:dndzone={{
|
|
items: dndItems,
|
|
flipDurationMs,
|
|
dragDisabled,
|
|
dropTargetStyle: {},
|
|
}}
|
|
onconsider={handleConsider}
|
|
onfinalize={handleFinalize}
|
|
class="list-none p-0 m-0"
|
|
>
|
|
{#each dndItems as item, index (item.dndId)}
|
|
<li class="outline-none">
|
|
<div
|
|
class="w-full flex items-center gap-2 p-3 hover:bg-white/5 transition-colors {currentIndex === index ? 'bg-white/10' : ''}"
|
|
>
|
|
<!-- Drag handle -->
|
|
<button
|
|
type="button"
|
|
aria-label="Drag to reorder"
|
|
class="p-1 cursor-grab touch-none text-gray-500 hover:text-white transition-colors"
|
|
onmousedown={startDrag}
|
|
ontouchstart={startDrag}
|
|
onkeydown={handleKeyDown}
|
|
>
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 6h2v2H8V6zm6 0h2v2h-2V6zM8 11h2v2H8v-2zm6 0h2v2h-2v-2zm-6 5h2v2H8v-2zm6 0h2v2h-2v-2z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Clickable area for track selection -->
|
|
<button
|
|
type="button"
|
|
onclick={() => onItemClick?.(index)}
|
|
class="flex-1 flex items-center gap-3 text-left min-w-0"
|
|
aria-label="Play {item.name}"
|
|
>
|
|
<!-- Index or playing indicator -->
|
|
<div class="w-6 text-center flex-shrink-0">
|
|
{#if currentIndex === index}
|
|
<svg class="w-4 h-4 mx-auto text-[var(--color-jellyfin)]" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
{:else}
|
|
<span class="text-sm text-gray-500">{index + 1}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Artwork -->
|
|
<div class="w-10 h-10 rounded bg-gray-800 flex-shrink-0 overflow-hidden">
|
|
{#if item.primaryImageTag}
|
|
<CachedImage
|
|
itemId={item.id}
|
|
imageType="Primary"
|
|
tag={item.primaryImageTag}
|
|
maxWidth={80}
|
|
alt={item.name}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium truncate {currentIndex === index ? 'text-[var(--color-jellyfin)]' : 'text-white'}">
|
|
{item.name}
|
|
</p>
|
|
{#if item.artists?.length}
|
|
<p class="text-xs text-gray-400 truncate">
|
|
{item.artists.join(", ")}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Duration -->
|
|
<span class="text-xs text-gray-500 flex-shrink-0">
|
|
{formatDuration(item.runTimeTicks)}
|
|
</span>
|
|
</button>
|
|
|
|
<!-- Remove button -->
|
|
<button
|
|
type="button"
|
|
onclick={(e) => handleRemove(e, index)}
|
|
class="p-1 rounded text-gray-500 hover:text-red-400 hover:bg-white/5 transition-colors"
|
|
aria-label="Remove from queue"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
</div>
|