initial commit with full project
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user