283 lines
8.3 KiB
Svelte
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>
|