Files
cinch/src/lib/components/VideoPreview.svelte
T

283 lines
8.3 KiB
Svelte

<script lang="ts">
import { app } from '$lib/stores/app.svelte';
import { config } from '$lib/stores/config.svelte';
import { generatePreview, streamUrl } from '$lib/utils/tauri';
interface Props {
currentTime?: number;
onTimeUpdate?: (time: number) => void;
onSeek?: (time: number) => void;
}
let { currentTime = $bindable(0), onTimeUpdate, onSeek }: Props = $props();
let videoEl: HTMLVideoElement | undefined = $state();
let playing = $state(false);
let volume = $state(config.current.preview_volume);
let showControls = $state(false);
let showVolume = $state(false);
let errorMsg = $state('');
let previewPath = $state('');
let generating = $state(false);
let videoReady = $state(false);
// try the original file first, fall back to generated H.264 proxy
let src = $derived(previewPath ? streamUrl(previewPath) : app.videoInfo?.path ? streamUrl(app.videoInfo.path) : '');
let prevVideoPath = '';
$effect(() => {
const path = app.videoInfo?.path;
if (!path || path === prevVideoPath) return;
prevVideoPath = path;
previewPath = '';
errorMsg = '';
generating = false;
videoReady = false;
});
async function handleVideoError() {
const path = app.videoInfo?.path;
if (!path || generating || previewPath) {
// already tried the proxy and it also failed
errorMsg = "Can't preview this video. It will still compress and trim correctly.";
return;
}
// original file can't play (probably unsupported codec) - generate H.264 proxy
generating = true;
errorMsg = '';
try {
const proxy = await generatePreview(path, app.videoInfo?.video_codec);
previewPath = proxy;
// src will update via $derived, video element will reload
} catch {
errorMsg = "Can't preview this video. It will still compress and trim correctly.";
}
generating = false;
}
export function seekTo(time: number) {
if (videoEl) {
videoEl.currentTime = time;
currentTime = time;
}
}
export function togglePlay() {
if (!videoEl) return;
if (videoEl.paused) {
// if we're outside the trim region, jump to the in point first
const inPt = app.trimRange?.start ?? 0;
const outPt = app.trimRange?.end ?? (app.videoInfo?.duration ?? 0);
if (videoEl.currentTime < inPt || videoEl.currentTime >= outPt) {
videoEl.currentTime = inPt;
}
videoEl.play();
playing = true;
} else {
videoEl.pause();
playing = false;
}
}
let lastReportedTime = 0;
function handleTimeUpdate() {
if (!videoEl) return;
// loop within trim region - always check, don't throttle
if (playing && app.trimRange) {
if (videoEl.currentTime >= app.trimRange.end) {
videoEl.currentTime = app.trimRange.start;
}
}
// throttle reactive updates to ~10Hz to prevent DOM thrashing
const now = performance.now();
if (now - lastReportedTime < 100) return;
lastReportedTime = now;
currentTime = videoEl.currentTime;
onTimeUpdate?.(videoEl.currentTime);
}
function handleEnded() {
// if trim range is set, loop back to in point
if (videoEl && app.trimRange) {
videoEl.currentTime = app.trimRange.start;
videoEl.play();
return;
}
playing = false;
}
$effect(() => {
if (videoEl) videoEl.volume = volume;
});
// persist volume to config
$effect(() => {
if (volume !== config.current.preview_volume) {
config.update({ preview_volume: volume });
}
});
// sync external seek
$effect(() => {
if (videoEl && Math.abs(videoEl.currentTime - currentTime) > 0.1) {
videoEl.currentTime = currentTime;
}
});
</script>
<div
class="relative flex items-center justify-center overflow-hidden cursor-pointer video-container"
style="aspect-ratio: {app.videoInfo?.width ?? 16} / {app.videoInfo?.height ?? 9}"
role="button"
tabindex="0"
onclick={togglePlay}
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); togglePlay(); } }}
onmouseenter={() => { showControls = true; }}
onmouseleave={() => { showControls = false; showVolume = false; }}
>
<!-- skeleton - visible until video is ready -->
{#if !videoReady}
<div class="video-skeleton">
{#if generating}
<span class="inline-block w-6 h-6 border-2 border-t-transparent rounded-full animate-spin" style="border-color: var(--color-accent-compress); border-top-color: transparent"></span>
<span style="font-family: var(--font-body); font-size: var(--text-xs); color: var(--color-text-disabled)">Generating preview...</span>
{:else}
<i class="ti ti-movie" style="font-size: 48px; color: var(--color-text-disabled); opacity: 0.3"></i>
{/if}
</div>
{/if}
<!-- video element - hidden until loaded, then fades in -->
{#if src && !generating}
<video
bind:this={videoEl}
src={src}
class="video-element"
class:video-element--ready={videoReady}
ontimeupdate={handleTimeUpdate}
onended={handleEnded}
onerror={handleVideoError}
onloadeddata={() => { errorMsg = ''; videoReady = true; }}
preload="metadata"
>
<track kind="captions" />
</video>
{#if errorMsg}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2" style="background: var(--color-bg-elevated); border-radius: var(--radius-lg)">
<i class="ti ti-alert-circle" style="font-size: 32px; color: var(--color-accent-warning)"></i>
<span style="font-family: var(--font-body); font-size: var(--text-sm); color: var(--color-text-secondary); text-align: center; padding: 0 16px;">
{errorMsg}
</span>
</div>
{/if}
{/if}
<!-- play/pause overlay -->
<div
class="absolute inset-0 flex items-center justify-center"
style="
background: rgba(0,0,0,0.15);
opacity: {showControls ? 1 : 0};
transition: opacity var(--transition-fast);
pointer-events: none;
"
>
<div
class="flex items-center justify-center rounded-full"
style="width: 48px; height: 48px; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px)"
>
<i
class={playing ? 'ti ti-player-pause' : 'ti ti-player-play'}
style="font-size: 22px; color: white; margin-left: {playing ? '0' : '2px'}"
></i>
</div>
</div>
<!-- volume control -->
<div
class="absolute bottom-2 right-2 flex items-center gap-1"
style="
opacity: {showControls ? 1 : 0};
transition: opacity var(--transition-fast);
pointer-events: {showControls ? 'auto' : 'none'};
"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="group"
>
{#if showVolume}
<input
type="range"
min="0"
max="1"
step="0.05"
bind:value={volume}
class="volume-slider w-16"
onclick={(e) => e.stopPropagation()}
/>
{/if}
<button
type="button"
class="flex items-center justify-center rounded cursor-pointer"
style="width: 28px; height: 28px; background: rgba(0,0,0,0.5); border: none; color: white; backdrop-filter: blur(4px)"
onclick={(e) => { e.stopPropagation(); showVolume = !showVolume; }}
>
<i class={volume === 0 ? 'ti ti-volume-off' : 'ti ti-volume'} style="font-size: 14px"></i>
</button>
</div>
</div>
<style>
.video-container {
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-bg-elevated);
}
.video-skeleton {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
justify-content: center;
border-radius: var(--radius-lg);
}
.video-element {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
border-radius: var(--radius-lg);
opacity: 0;
transition: opacity 500ms ease-in-out;
}
.video-element--ready {
opacity: 1;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 2px;
background: var(--color-bg-elevated);
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-accent-compress);
cursor: pointer;
}
</style>