feat: add background download manager with progress reporting

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-21 11:00:46 -04:00
parent 3e469a931f
commit 088def2ee4
7 changed files with 292 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke, Channel } from '@tauri-apps/api/core';
import { preferences } from '$lib/stores/preferences.svelte';
export interface VideoMetadata {
@@ -10,9 +10,50 @@ export interface VideoMetadata {
streamUrl: string;
}
type DownloadEvent =
| { event: 'progress'; data: { percent: number } }
| { event: 'filePath'; data: { path: string } }
| { event: 'finished'; data: { success: boolean; path: string } }
| { event: 'error'; data: { message: string } };
export async function resolveUrl(url: string): Promise<VideoMetadata> {
return invoke<VideoMetadata>('resolve_url', {
url,
cookieSource: preferences.cookieSource,
});
}
export async function startDownload(
url: string,
onProgress: (percent: number) => void,
onFilePath: (path: string) => void,
onFinished: (success: boolean, path: string) => void,
onError: (message: string) => void
): Promise<void> {
const onEvent = new Channel<DownloadEvent>();
onEvent.onmessage = (message) => {
switch (message.event) {
case 'progress':
onProgress(message.data.percent);
break;
case 'filePath':
onFilePath(message.data.path);
break;
case 'finished':
onFinished(message.data.success, message.data.path);
break;
case 'error':
onError(message.data.message);
break;
default: {
const _exhaustive: never = message;
throw new Error(`Unhandled download event: ${(_exhaustive as DownloadEvent).event}`);
}
}
};
await invoke('start_download', {
url,
cookieSource: preferences.cookieSource,
onEvent,
});
}

View File

@@ -1,5 +1,36 @@
<script lang="ts">
import { session } from '$lib/stores/videoSession.svelte';
let progressPercent = $derived(Math.round(session.downloadProgress * 100));
let isDownloading = $derived(
session.status === 'ready' &&
session.downloadStatus === 'downloading' &&
session.downloadProgress > 0 &&
session.downloadProgress < 1
);
let isDownloaded = $derived(
session.localFilePath !== null &&
(session.downloadStatus === 'complete' || session.downloadProgress >= 1)
);
</script>
<div class="status-bar">
<span class="status-text">Ready</span>
{#if isDownloading}
<div class="progress-bar">
<div class="progress-fill" style="width: {progressPercent}%"></div>
</div>
<span class="status-text">Downloading: {progressPercent}%</span>
{:else if isDownloaded}
<span class="status-text success">✓ Downloaded: {session.title}</span>
{:else if session.status === 'resolving'}
<span class="status-text">Resolving URL…</span>
{:else if session.status === 'ready' && session.downloadStatus === 'downloading'}
<span class="status-text">Starting download…</span>
{:else if session.downloadStatus === 'failed'}
<span class="status-text">Download failed</span>
{:else}
<span class="status-text">Ready</span>
{/if}
</div>
<style>
@@ -12,5 +43,25 @@
font-size: 12px;
color: var(--text-secondary);
min-height: 24px;
gap: 8px;
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--bg-primary);
border-radius: 2px;
overflow: hidden;
max-width: 200px;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s ease;
}
.success {
color: var(--success);
}
</style>

View File

@@ -1,6 +1,12 @@
<script lang="ts">
import { resolveUrl } from '$lib/bindings/video';
import { session, setMetadata, setError, setResolving } from '$lib/stores/videoSession.svelte';
import {
session,
setMetadata,
setError,
setResolving,
beginDownload,
} from '$lib/stores/videoSession.svelte';
let inputValue = $state('');
@@ -12,6 +18,7 @@
try {
const meta = await resolveUrl(trimmed);
setMetadata(meta);
void beginDownload();
} catch (e) {
setError(String(e));
}

View File

@@ -1,7 +1,9 @@
import type { VideoMetadata } from '$lib/bindings/video';
import { startDownload } from '$lib/bindings/video';
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
export const session = $state({
status: 'idle' as SessionStatus,
@@ -16,6 +18,7 @@ export const session = $state({
isPlaying: false,
localFilePath: null as string | null,
downloadProgress: 0,
downloadStatus: 'idle' as DownloadStatus,
keyframePositions: [] as number[],
waveformPeaks: [] as number[],
thumbnailSpritesheets: [] as ThumbnailSpritesheet[],
@@ -33,6 +36,7 @@ export function setMetadata(meta: VideoMetadata) {
session.localFilePath = null;
session.downloadProgress = 0;
session.downloadStatus = 'idle';
session.keyframePositions = [];
session.waveformPeaks = [];
session.thumbnailSpritesheets = [];
@@ -49,6 +53,7 @@ function clearMediaFields() {
session.isPlaying = false;
session.localFilePath = null;
session.downloadProgress = 0;
session.downloadStatus = 'idle';
session.keyframePositions = [];
session.waveformPeaks = [];
session.thumbnailSpritesheets = [];
@@ -71,3 +76,39 @@ export function reset() {
session.status = 'idle';
session.error = null;
}
export async function beginDownload() {
if (!session.url) return;
session.downloadStatus = 'downloading';
session.downloadProgress = 0;
session.localFilePath = null;
try {
await startDownload(
session.url,
(percent) => {
session.downloadProgress = percent;
},
(path) => {
session.localFilePath = path;
},
(success, path) => {
if (success) {
session.downloadProgress = 1.0;
session.localFilePath = path;
session.downloadStatus = 'complete';
} else {
session.downloadStatus = 'failed';
}
},
(message) => {
session.downloadStatus = 'failed';
console.error('Download error:', message);
}
);
} catch (e) {
session.downloadStatus = 'failed';
console.error('Download failed:', e);
}
}