Duncan Tourolle d01c2aab9f
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m39s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 36s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Failing after 1m57s
Migrate all IPC call sites to typed tauri-specta commands.*
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.
2026-06-21 08:47:04 +02:00

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>