initial commit with full project
This commit is contained in:
659
src/lib/components/Timeline.svelte
Normal file
659
src/lib/components/Timeline.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user