First working POC
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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);
|
||||
|
||||
// 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 = repo.getVideoDownloadUrl(itemId, quality);
|
||||
console.log(" Stream URL obtained");
|
||||
|
||||
// Get target directory
|
||||
const targetDir = await invoke<string>("storage_get_path");
|
||||
|
||||
// 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 invoke("start_download", {
|
||||
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
|
||||
showQualityPicker = true;
|
||||
} else {
|
||||
// Show quality picker
|
||||
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
|
||||
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>
|
||||
|
||||
<!-- Quality picker dropdown -->
|
||||
{#if showQualityPicker}
|
||||
<div class="absolute z-50 mt-1 right-0 w-40 bg-[var(--color-surface)] rounded-lg shadow-xl border border-gray-700 overflow-hidden">
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close -->
|
||||
{#if showQualityPicker}
|
||||
<button
|
||||
class="fixed inset-0 z-40"
|
||||
onclick={() => showQualityPicker = false}
|
||||
aria-label="Close quality picker"
|
||||
></button>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user