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.
294 lines
9.0 KiB
Svelte
294 lines
9.0 KiB
Svelte
<script lang="ts">
|
|
import { downloads } from "$lib/stores/downloads";
|
|
import { auth } from "$lib/stores/auth";
|
|
import { commands } from "$lib/api/bindings";
|
|
import { QUALITY_PRESETS, type QualityPreset } from "$lib/api/quality-presets";
|
|
|
|
interface Props {
|
|
itemId: string;
|
|
itemName?: string;
|
|
// For movies
|
|
isMovie?: boolean;
|
|
// For episodes
|
|
seriesName?: string;
|
|
seasonName?: string;
|
|
episodeNumber?: number;
|
|
seasonNumber?: number;
|
|
size?: "sm" | "md" | "lg";
|
|
className?: string;
|
|
}
|
|
|
|
let {
|
|
itemId,
|
|
itemName = "",
|
|
isMovie = false,
|
|
seriesName,
|
|
seasonName,
|
|
episodeNumber,
|
|
seasonNumber,
|
|
size = "md",
|
|
className = ""
|
|
}: Props = $props();
|
|
|
|
const sizeClasses = {
|
|
sm: "w-4 h-4",
|
|
md: "w-5 h-5",
|
|
lg: "w-6 h-6",
|
|
};
|
|
|
|
let isProcessing = $state(false);
|
|
let showQualityPicker = $state(false);
|
|
let buttonEl: HTMLButtonElement;
|
|
let dropdownPos = $state({ top: 0, left: 0 });
|
|
|
|
// Find download for this item
|
|
const downloadInfo = $derived(
|
|
Object.values($downloads.downloads).find((d) => d.itemId === itemId)
|
|
);
|
|
|
|
const status = $derived(downloadInfo?.status || "not_downloaded");
|
|
const progress = $derived(downloadInfo?.progress || 0);
|
|
|
|
async function startDownload(quality: QualityPreset) {
|
|
showQualityPicker = false;
|
|
if (isProcessing) return;
|
|
|
|
isProcessing = true;
|
|
try {
|
|
const userId = $auth.user?.id;
|
|
if (!userId) {
|
|
console.error("No user ID found");
|
|
return;
|
|
}
|
|
|
|
const repo = auth.getRepository();
|
|
|
|
console.log("🎬 Starting video download for item:", itemId, "quality:", quality);
|
|
|
|
// Get stream URL based on quality
|
|
const streamUrl = await repo.getVideoDownloadUrl(itemId, quality);
|
|
console.log(" Stream URL obtained");
|
|
|
|
// Get target directory
|
|
const targetDir = await commands.storageGetPath();
|
|
|
|
// Create file path
|
|
const safeName = (itemName || itemId).replace(/[/\\:*?"<>|]/g, "_");
|
|
let filePath: string;
|
|
|
|
if (isMovie) {
|
|
filePath = `videos/movies/${safeName}.mp4`;
|
|
} else if (seriesName && seasonNumber !== undefined && episodeNumber !== undefined) {
|
|
const safeSeriesName = seriesName.replace(/[/\\:*?"<>|]/g, "_");
|
|
filePath = `videos/${safeSeriesName}/S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')}_${safeName}.mp4`;
|
|
} else {
|
|
filePath = `videos/${safeName}.mp4`;
|
|
}
|
|
|
|
console.log(" File path:", filePath);
|
|
|
|
// Queue download with video metadata
|
|
const downloadId = await downloads.downloadVideo(
|
|
itemId,
|
|
userId,
|
|
filePath,
|
|
"video/mp4",
|
|
isMovie ? 500 : (1000 - (episodeNumber || 0)), // Movies have medium priority, episodes ordered by number
|
|
itemName || undefined,
|
|
quality,
|
|
seriesName,
|
|
seasonName,
|
|
episodeNumber,
|
|
seasonNumber
|
|
);
|
|
console.log(" Download queued with ID:", downloadId);
|
|
|
|
// Pin the item metadata
|
|
await downloads.pinItem(itemId);
|
|
|
|
// Actually start the download
|
|
await commands.startDownload(downloadId, streamUrl, targetDir);
|
|
console.log(" Download started");
|
|
} catch (error) {
|
|
console.error("Failed to start video download:", error);
|
|
} finally {
|
|
isProcessing = false;
|
|
}
|
|
}
|
|
|
|
async function handleClick() {
|
|
if (isProcessing) return;
|
|
|
|
if (status === "completed") {
|
|
// Delete download
|
|
if (downloadInfo?.id) {
|
|
await downloads.delete(downloadInfo.id);
|
|
// Unpin when deleted
|
|
await downloads.unpinItem(itemId);
|
|
}
|
|
} else if (status === "downloading" || status === "pending") {
|
|
// Cancel download
|
|
if (downloadInfo?.id) {
|
|
await downloads.cancel(downloadInfo.id);
|
|
}
|
|
} else if (status === "failed") {
|
|
// Show quality picker to retry
|
|
openQualityPicker();
|
|
} else {
|
|
// Show quality picker
|
|
openQualityPicker();
|
|
}
|
|
}
|
|
|
|
function openQualityPicker() {
|
|
if (buttonEl) {
|
|
const rect = buttonEl.getBoundingClientRect();
|
|
const dropdownWidth = 160; // w-40
|
|
const padding = 8;
|
|
let left = rect.right - dropdownWidth;
|
|
// Clamp to viewport bounds
|
|
left = Math.max(padding, Math.min(left, window.innerWidth - dropdownWidth - padding));
|
|
dropdownPos = { top: rect.bottom + 4, left };
|
|
}
|
|
showQualityPicker = true;
|
|
}
|
|
|
|
function getTitle(): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return "Downloaded - Click to remove";
|
|
case "downloading":
|
|
return `Downloading... ${Math.round(progress * 100)}%`;
|
|
case "pending":
|
|
return "Queued for download";
|
|
case "paused":
|
|
return "Download paused";
|
|
case "failed":
|
|
return "Download failed - Click to retry";
|
|
default:
|
|
return "Download for offline playback";
|
|
}
|
|
}
|
|
|
|
function getColor(): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return "text-green-500 hover:text-green-400";
|
|
case "downloading":
|
|
case "pending":
|
|
return "text-blue-500 hover:text-blue-400";
|
|
case "failed":
|
|
return "text-red-500 hover:text-red-400";
|
|
default:
|
|
return "text-gray-400 hover:text-white";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="relative">
|
|
<button
|
|
bind:this={buttonEl}
|
|
onclick={handleClick}
|
|
disabled={isProcessing}
|
|
class="p-2 rounded-full transition-all {getColor()} {isProcessing
|
|
? 'opacity-50 cursor-wait'
|
|
: ''} {className}"
|
|
title={getTitle()}
|
|
aria-label={getTitle()}
|
|
>
|
|
<div class="relative {sizeClasses[size]}">
|
|
{#if status === "downloading"}
|
|
<!-- Progress ring -->
|
|
<svg class="absolute inset-0 -rotate-90" viewBox="0 0 24 24">
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
opacity="0.2"
|
|
/>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-dasharray={2 * Math.PI * 10}
|
|
stroke-dashoffset={2 * Math.PI * 10 * (1 - progress)}
|
|
stroke-linecap="round"
|
|
class="transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
<svg
|
|
class="absolute inset-0 m-auto w-3 h-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
stroke-width="2.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 4v12m0 0l-4-4m4 4l4-4"
|
|
/>
|
|
</svg>
|
|
{:else if status === "completed"}
|
|
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{:else if status === "pending"}
|
|
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2m6-2a10 10 0 11-20 0 10 10 0 0120 0z" />
|
|
</svg>
|
|
{:else if status === "failed"}
|
|
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
{:else}
|
|
<svg class={sizeClasses[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4m-9 7h10" />
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Quality picker dropdown (fixed position, viewport-clamped) -->
|
|
{#if showQualityPicker}
|
|
<button
|
|
class="fixed inset-0 z-40"
|
|
onclick={() => showQualityPicker = false}
|
|
aria-label="Close quality picker"
|
|
></button>
|
|
<div
|
|
class="fixed z-50 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden"
|
|
style="top: {dropdownPos.top}px; left: {dropdownPos.left}px;"
|
|
>
|
|
<div class="p-2 text-xs text-gray-400 border-b border-gray-700">
|
|
Select Quality
|
|
</div>
|
|
{#each Object.entries(QUALITY_PRESETS) as [key, preset]}
|
|
<button
|
|
onclick={() => startDownload(key as QualityPreset)}
|
|
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-700 transition-colors flex justify-between items-center"
|
|
>
|
|
<span>{preset.label}</span>
|
|
{#if preset.videoBitrate}
|
|
<span class="text-xs text-gray-500">{Math.round(preset.videoBitrate / 1_000_000)}Mbps</span>
|
|
{:else}
|
|
<span class="text-xs text-gray-500">Direct</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
<button
|
|
onclick={() => showQualityPicker = false}
|
|
class="w-full px-3 py-2 text-left text-sm text-gray-400 hover:bg-gray-700 border-t border-gray-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{/if}
|