initial commit with full project

This commit is contained in:
2026-04-26 17:50:04 +03:00
commit 53044e7d40
68 changed files with 34115 additions and 0 deletions

View File

@@ -0,0 +1,659 @@
<script lang="ts">
import { onMount } from 'svelte';
import { streamUrl } from '$lib/utils/tauri';
import { app } from '$lib/stores/app.svelte';
import { formatTimecode, formatTimecodeShort } from '$lib/utils/format';
// SegmentedControl replaced with custom toggle
interface Props {
currentTime: number;
onSeek: (time: number) => void;
}
let { currentTime, onSeek }: Props = $props();
let trackRef: HTMLDivElement | undefined = $state();
let innerRef: HTMLDivElement | undefined = $state();
let dragging: 'in' | 'out' | 'scrub' | null = $state(null);
let hoverX: number | null = $state(null);
let hoverTime: number | null = $state(null);
let zoomLevel = $state(1);
let animating = $state(false); // true when trim handles should animate (button clicks, not drags)
let inInput = $state('');
let outInput = $state('');
function parseTimecode(str: string): number {
const m = str.trim().match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/);
if (!m) return -1;
const h = parseInt(m[1]);
const min = parseInt(m[2]);
const sec = parseInt(m[3]);
const ms = m[4] ? parseInt(m[4].padEnd(3, '0')) : 0;
return h * 3600 + min * 60 + sec + ms / 1000;
}
function commitIn() {
const t = parseTimecode(inInput);
if (t >= 0 && t < outTime) {
app.setTrimRange(Math.max(0, t), outTime);
}
inInput = formatTimecode(inTime);
}
function commitOut() {
const t = parseTimecode(outInput);
if (t > inTime && t <= duration) {
app.setTrimRange(inTime, t);
}
outInput = formatTimecode(outTime);
}
function handleInKey(e: KeyboardEvent) {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}
function handleOutKey(e: KeyboardEvent) {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}
// keep input values in sync when trim changes externally (drag etc)
$effect(() => { inInput = formatTimecode(inTime); });
$effect(() => { outInput = formatTimecode(outTime); });
let duration = $derived(app.videoInfo?.duration ?? 0);
let keyframes = $derived(app.videoInfo?.keyframe_times ?? []);
let inTime = $derived(app.trimRange?.start ?? 0);
let outTime = $derived(app.trimRange?.end ?? duration);
let inPct = $derived(duration > 0 ? (inTime / duration) * 100 : 0);
let outPct = $derived(duration > 0 ? (outTime / duration) * 100 : 100);
let playheadPct = $derived(duration > 0 ? (currentTime / duration) * 100 : 0);
let selectedDuration = $derived(outTime - inTime);
let trimModeLabel = $derived(app.trimMode === 'keyframe' ? 'Keyframe snap' : 'Smart cut');
let modeOptions = ['Keyframe snap', 'Smart cut'];
function pctFromEvent(e: MouseEvent): number {
if (!trackRef || !innerRef) return 0;
const rect = trackRef.getBoundingClientRect();
const scrollLeft = trackRef.scrollLeft;
const innerWidth = innerRef.offsetWidth;
const x = e.clientX - rect.left + scrollLeft;
return Math.max(0, Math.min(100, (x / innerWidth) * 100));
}
function timeFromPct(pct: number): number {
return (pct / 100) * duration;
}
function nearestKeyframe(time: number): number {
if (keyframes.length === 0) return time;
let best = keyframes[0];
let dist = Math.abs(time - best);
for (const kf of keyframes) {
const d = Math.abs(time - kf);
if (d < dist) { best = kf; dist = d; }
}
return best;
}
function snapTime(time: number): number {
if (app.trimMode === 'keyframe') return nearestKeyframe(time);
return time;
}
let scrubStartX = 0;
let scrubStartTime = 0;
function handlePointerDown(e: PointerEvent, mode: 'scrub' | 'in' | 'out') {
if (dragging) return;
dragging = mode;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
if (mode === 'scrub') {
const pct = pctFromEvent(e);
const time = timeFromPct(pct);
onSeek(time);
scrubStartX = e.clientX;
scrubStartTime = time;
}
}
function handlePointerMove(e: PointerEvent) {
if (!trackRef || !innerRef || !dragging) return;
const rect = trackRef.getBoundingClientRect();
const innerWidth = innerRef.offsetWidth;
if (dragging === 'scrub') {
const vertDist = Math.abs(e.clientY - (rect.top + rect.height / 2));
const speed = vertDist < 30 ? 1 : vertDist < 80 ? 0.5 : vertDist < 150 ? 0.25 : 0.1;
const dx = e.clientX - scrubStartX;
const pxPerSecond = innerWidth / duration;
const timeDelta = (dx * speed) / pxPerSecond;
const newTime = Math.max(0, Math.min(duration, scrubStartTime + timeDelta));
scrubStartX = e.clientX;
scrubStartTime = newTime;
onSeek(newTime);
return;
}
const scrollLeft = trackRef.scrollLeft;
const x = e.clientX - rect.left + scrollLeft;
const pct = Math.max(0, Math.min(100, (x / innerWidth) * 100));
const time = timeFromPct(pct);
if (dragging === 'in') {
const snapped = snapTime(Math.min(time, outTime - 0.1));
app.setTrimRange(Math.max(0, snapped), outTime);
onSeek(Math.max(0, snapped));
} else if (dragging === 'out') {
const snapped = snapTime(Math.max(time, inTime + 0.1));
app.setTrimRange(inTime, Math.min(duration, snapped));
onSeek(Math.min(duration, snapped));
}
}
function handlePointerUp() {
dragging = null;
}
function handleHoverMove(e: MouseEvent) {
if (!trackRef || !innerRef || dragging) return;
const rect = trackRef.getBoundingClientRect();
const scrollLeft = trackRef.scrollLeft;
const innerWidth = innerRef.offsetWidth;
const x = e.clientX - rect.left + scrollLeft;
hoverTime = timeFromPct(Math.max(0, Math.min(100, (x / innerWidth) * 100)));
// viewport-relative tooltip position
hoverX = Math.max(62, Math.min(rect.width - 62, e.clientX - rect.left));
}
function handleMouseLeave() {
hoverX = null;
hoverTime = null;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
if (e.deltaY < 0) zoomLevel = Math.min(8, zoomLevel * 1.15);
else zoomLevel = Math.max(1, zoomLevel / 1.15);
}
function handleModeChange(val: string) {
app.trimMode = val === 'Keyframe snap' ? 'keyframe' : 'smart';
}
function handleTrackKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
e.preventDefault();
app.seek(app.currentTime - (e.shiftKey ? 5 : 1));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
app.seek(app.currentTime + (e.shiftKey ? 5 : 1));
} else if (e.key === 'Home') {
e.preventDefault();
app.seek(0);
} else if (e.key === 'End') {
e.preventDefault();
app.seek(duration);
} else if (e.key === 'i' || e.key === 'I') {
e.preventDefault();
app.setTrimRange(currentTime, outTime);
onSeek(currentTime);
} else if (e.key === 'o' || e.key === 'O') {
e.preventDefault();
app.setTrimRange(inTime, currentTime);
onSeek(currentTime);
}
}
</script>
<div class="flex flex-col gap-2 w-full">
<!-- main filmstrip area -->
<div class="tl-track-outer">
<div
bind:this={trackRef}
class="relative w-full cursor-crosshair select-none"
class:tl-animating={animating}
style="height: 72px; border-radius: var(--radius-md); overflow-x: auto; overflow-y: visible; background: var(--color-bg-elevated); border: 1px solid var(--color-border); scrollbar-width: none;"
role="slider"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
tabindex="0"
onpointerdown={(e) => handlePointerDown(e, 'scrub')}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onmousemove={handleHoverMove}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
onkeydown={handleTrackKeydown}
>
<div bind:this={innerRef} class="tl-inner" style="width: calc(100% * {zoomLevel}); position: relative; height: 100%;">
<!-- thumbnail strip -->
{#if app.thumbnails.length > 0}
<div
class="absolute inset-0 flex"
style="opacity: 0.8"
>
{#each app.thumbnails as thumb, i}
<div
class="h-full flex-1"
style="background: url('{streamUrl(thumb)}') center/cover no-repeat"
></div>
{/each}
</div>
{/if}
<!-- keyframe color segments -->
{#each keyframes as kf, i}
{#if i > 0}
{@const prevPct = (keyframes[i - 1] / duration) * 100}
{@const kfPct = (kf / duration) * 100}
<div
class="absolute top-0 h-full"
style="
left: {prevPct}%;
width: {kfPct - prevPct}%;
background: {i % 2 === 0 ? 'rgba(5,150,105,0.05)' : 'rgba(37,99,235,0.05)'};
"
></div>
{/if}
{/each}
<!-- dark overlay for unselected regions -->
{#if app.trimRange}
<div
class="absolute top-0 h-full"
style="left: 0; width: {inPct}%; background: rgba(0,0,0,0.35)"
></div>
<div
class="absolute top-0 h-full"
style="left: {outPct}%; right: 0; background: rgba(0,0,0,0.35)"
></div>
{/if}
<!-- selected region top/bottom border -->
{#if app.trimRange}
<div
class="absolute z-10 pointer-events-none"
style="
left: calc({inPct}%);
width: calc({outPct - inPct}%);
top: 0;
height: 100%;
border-top: 2px solid var(--color-accent-trim);
border-bottom: 2px solid var(--color-accent-trim);
"
></div>
{/if}
<!-- IN handle -->
{#if app.trimRange}
<div
class="trim-handle trim-handle--in"
style="left: calc({inPct}% - {inPct > 0 ? 12 : 0}px);"
onpointerdown={(e) => handlePointerDown(e, 'in')}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
role="slider"
aria-label="Trim in point"
aria-valuenow={inTime}
tabindex="0"
>
<div class="trim-handle-inner">
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
</div>
<!-- OUT handle -->
<div
class="trim-handle trim-handle--out"
style="left: calc({outPct}% - {outPct >= 100 ? 12 : 0}px);"
onpointerdown={(e) => handlePointerDown(e, 'out')}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
role="slider"
aria-label="Trim out point"
aria-valuenow={outTime}
tabindex="0"
>
<div class="trim-handle-inner">
<svg width="6" height="20" viewBox="0 0 6 20" fill="none">
<line x1="1" y1="3" x2="1" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
<line x1="4.5" y1="3" x2="4.5" y2="17" stroke="rgba(255,255,255,0.7)" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
</div>
{/if}
<!-- playhead -->
<div
class="playhead-wrapper"
style="--ph-pct: {playheadPct}"
>
<div class="playhead-line"></div>
</div>
</div>
</div>
<!-- hover tooltip -->
{#if hoverX !== null && hoverTime !== null && !dragging}
<div
class="absolute z-40 pointer-events-none"
style="
bottom: calc(100% + 8px);
left: {hoverX}px;
transform: translateX(-50%);
"
>
<div
class="flex flex-col items-center gap-1"
style="
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 4px 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
"
>
<!-- mini thumb -->
{#if duration > 0 && app.thumbnails.length > 0}
{@const thumbIdx = Math.min(Math.floor((hoverTime / duration) * app.thumbnails.length), app.thumbnails.length - 1)}
<div
class="rounded"
style="width: 120px; height: 68px; background: url('{streamUrl(app.thumbnails[thumbIdx])}') center/cover no-repeat"
></div>
{:else}
<div
class="rounded"
style="width: 120px; height: 68px; background: var(--color-bg-surface)"
></div>
{/if}
<span class="font-mono text-xs" style="color: var(--color-text-secondary)">
{formatTimecode(hoverTime)}
</span>
</div>
</div>
{/if}
</div>
<!-- below timeline: controls row -->
<div class="tl-controls">
<!-- left: cut mode toggle -->
<div class="tl-mode">
<button
type="button"
class="tl-mode-btn"
class:tl-mode-btn--active={app.trimMode === 'keyframe'}
onclick={() => handleModeChange('Keyframe snap')}
>
<i class="ti ti-scissors" style="font-size: 13px"></i>
Keyframe
</button>
<button
type="button"
class="tl-mode-btn"
class:tl-mode-btn--active={app.trimMode === 'smart'}
onclick={() => handleModeChange('Smart cut')}
>
<i class="ti ti-cut" style="font-size: 13px"></i>
Smart
</button>
</div>
<!-- right: timecodes -->
<div class="tl-times">
<div class="tl-time-field">
<button
type="button"
class="tl-time-label tl-time-label--btn"
title="Set in point to current position"
onclick={() => { animating = true; app.setTrimRange(currentTime, outTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
>IN</button>
<input
type="text"
class="tl-time-input"
bind:value={inInput}
onblur={commitIn}
onkeydown={handleInKey}
onfocus={(e) => (e.target as HTMLInputElement).select()}
/>
<span class="tl-kbd-hint"><kbd>I</kbd></span>
</div>
<div class="tl-time-field">
<button
type="button"
class="tl-time-label tl-time-label--btn"
title="Set out point to current position"
onclick={() => { animating = true; app.setTrimRange(inTime, currentTime); onSeek(currentTime); setTimeout(() => { animating = false; }, 500); }}
>OUT</button>
<input
type="text"
class="tl-time-input"
bind:value={outInput}
onblur={commitOut}
onkeydown={handleOutKey}
onfocus={(e) => (e.target as HTMLInputElement).select()}
/>
<span class="tl-kbd-hint"><kbd>O</kbd></span>
</div>
<button
type="button"
class="tl-duration"
title="Reset trim to full video"
onclick={() => { animating = true; app.clearTrimRange(); setTimeout(() => { animating = false; }, 500); }}
>
<i class="ti ti-clock" style="font-size: 12px"></i>
{formatTimecodeShort(selectedDuration)}
</button>
</div>
</div>
</div>
<style>
.tl-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tl-mode {
display: flex;
gap: 2px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 2px;
}
.tl-mode-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-radius: 5px;
background: none;
color: var(--color-text-disabled);
font-family: var(--font-body);
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
white-space: nowrap;
}
.tl-mode-btn:hover {
color: var(--color-text-secondary);
}
.tl-mode-btn--active {
background: var(--color-accent-trim);
color: white;
}
.tl-mode-btn--active:hover {
color: white;
}
.tl-times {
display: flex;
align-items: center;
gap: 8px;
}
.tl-time-field {
display: flex;
align-items: stretch;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 5px;
overflow: hidden;
}
.tl-time-field:focus-within {
border-color: var(--color-accent-trim);
}
.tl-time-label {
display: flex;
align-items: center;
padding: 0 8px;
font-family: var(--font-body);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-disabled);
background: var(--color-bg-elevated);
border: none;
border-right: 1px solid var(--color-border);
user-select: none;
}
.tl-time-label--btn {
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
}
.tl-time-label--btn:hover {
color: var(--color-accent-trim);
background: var(--color-bg-surface);
}
.tl-time-input {
width: 90px;
padding: 5px 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--color-text-secondary);
background: none;
border: none;
outline: none;
}
.tl-time-input:focus {
color: var(--color-text-primary);
}
.tl-duration {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
color: var(--color-text-primary);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 5px;
cursor: pointer;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.tl-duration:hover {
border-color: var(--color-text-secondary);
color: var(--color-accent-compress);
}
/* when animating is active (button clicks), transition all positioned elements */
.tl-animating .trim-handle,
.tl-animating .trim-handle ~ div,
:global(.tl-animating) :is([style*="left:"], [style*="width:"]) {
transition: left 500ms ease-in-out, width 500ms ease-in-out, right 500ms ease-in-out;
}
.tl-track-outer {
position: relative;
height: 72px;
}
.tl-inner {
min-width: 100%;
}
.playhead-wrapper {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 30;
will-change: transform;
transform: translateX(calc(var(--ph-pct) * 1% - 1px));
}
.playhead-line {
width: 2px;
height: 100%;
background: var(--color-accent-compress);
box-shadow: 0 0 6px rgba(5,150,105,0.7), 0 0 12px rgba(5,150,105,0.4);
}
.tl-kbd-hint {
display: flex;
align-items: center;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 9px;
font-weight: 600;
color: var(--color-text-disabled);
user-select: none;
}
.tl-kbd-hint kbd {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 1px 4px;
font-size: 9px;
}
.trim-handle {
position: absolute;
top: 0;
height: 100%;
width: 14px;
z-index: 20;
cursor: ew-resize;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-accent-trim);
}
.trim-handle--in {
border-radius: 4px 0 0 4px;
}
.trim-handle--out {
border-radius: 0 4px 4px 0;
}
.trim-handle-inner {
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), opacity var(--transition-fast);
opacity: 0.7;
}
.trim-handle:hover .trim-handle-inner {
opacity: 1;
transform: scaleX(1.4);
}
</style>