Files
gui-video-clipper/src/lib/components/ClipList.svelte

423 lines
11 KiB
Svelte

<script lang="ts">
import {
clipStore,
selectClip,
removeClip,
updateClip,
type Clip,
} from '$lib/stores/clips.svelte';
import { formatTime, parseTime } from '$lib/utils/time';
import { session } from '$lib/stores/videoSession.svelte';
import { seekTo } from '$lib/transport/playback';
let {
onExport,
position = 'bottom',
}: { onExport?: () => void; position?: 'bottom' | 'left' } = $props();
let clips = $derived(clipStore.clips);
let selectedId = $derived(clipStore.selectedClipId);
let isSidebar = $derived(position === 'left');
function handleSelect(id: string) {
selectClip(id);
const clip = clips.find((c) => c.id === id);
if (clip) {
seekTo(clip.startTime);
}
}
function handleTimeEdit(clipId: string, field: 'startTime' | 'endTime', value: string) {
const parsed = parseTime(value);
if (parsed !== null) {
updateClip(clipId, { [field]: parsed });
}
}
function handleLabelEdit(clipId: string, value: string) {
updateClip(clipId, { label: value });
}
function handleContainerClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('clip-list')) {
selectClip(null);
}
}
function handlePlayClip(clip: Clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
function handleGoToStart(clip: Clip) {
seekTo(clip.startTime);
}
function handleGoToEnd(clip: Clip) {
seekTo(clip.endTime);
}
</script>
<div
class="clip-list"
class:sidebar={isSidebar}
role="listbox"
tabindex="0"
onclick={handleContainerClick}
onkeydown={(e) => {
if (e.key === 'Escape') {
selectClip(null);
}
}}
>
{#if clips.length === 0}
<div class="empty">No clips yet — press I to mark in-point, O to mark out-point</div>
{:else}
<div class="header">
<span>CLIPS ({clips.length})</span>
</div>
{#each clips as clip (clip.id)}
<div
class="clip-row"
class:selected={clip.id === selectedId}
class:sidebar={isSidebar}
role="option"
aria-selected={clip.id === selectedId}
tabindex="0"
onclick={() => handleSelect(clip.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(clip.id);
}
}}
>
{#if isSidebar}
<!-- Sidebar (vertical card) layout -->
<div class="clip-card">
<div class="card-row">
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input sidebar-label"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
<div class="card-row times">
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
</div>
{#if clip.id === selectedId}
<div class="clip-actions">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{/if}
</div>
{:else}
<!-- Bottom (wide row) layout -->
<span class="color-swatch" style="background: {clip.color}"></span>
<input
class="label-input"
type="text"
value={clip.label}
onchange={(e) => handleLabelEdit(clip.id, (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<input
class="time-input"
type="text"
value={formatTime(clip.startTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'startTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
<span class="arrow">→</span>
<input
class="time-input"
type="text"
value={formatTime(clip.endTime)}
onchange={(e) =>
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
onclick={(e) => e.stopPropagation()}
/>
{#if clip.id === selectedId}
<div class="clip-actions inline">
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
title="Play clip"
>▶</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToStart(clip); }}
title="Go to start"
>|◀</button>
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); handleGoToEnd(clip); }}
title="Go to end"
>▶|</button>
<button
class="action-btn danger"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
</div>
{:else}
<button
class="delete-btn"
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
title="Delete clip"
>✕</button>
{/if}
{/if}
</div>
{/each}
<div class="list-actions">
{#if selectedId}
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
{/if}
{#if onExport}
<button type="button" onclick={() => onExport()}>Export All</button>
{/if}
</div>
{/if}
</div>
<style>
.clip-list {
padding: 8px 12px;
background: var(--bg-secondary);
height: 100%;
min-height: 40px;
overflow-y: auto;
border-top: 1px solid var(--border);
box-sizing: border-box;
}
.clip-list.sidebar {
border-top: none;
height: 100%;
padding: 8px 6px;
}
.empty {
color: var(--text-muted);
font-size: 13px;
padding: 8px 0;
}
.header {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.list-actions {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
}
.list-actions button {
font-size: 13px;
padding: 6px 12px;
}
.deselect-btn {
color: var(--text-secondary);
background: var(--bg-tertiary);
}
/* --- Bottom (wide) row layout --- */
.clip-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
width: 100%;
text-align: left;
background: transparent;
border: 1px solid transparent;
}
.clip-row:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.clip-row.selected {
border-color: var(--accent);
background: var(--bg-tertiary);
}
/* --- Sidebar (vertical card) layout --- */
.clip-row.sidebar {
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 6px;
}
.clip-card {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.card-row {
display: flex;
align-items: center;
gap: 6px;
}
.card-row.times {
justify-content: center;
}
/* --- Shared elements --- */
.color-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
flex-shrink: 0;
}
.label-input {
background: transparent;
border: none;
color: var(--text-primary);
font-size: 13px;
width: 80px;
padding: 2px 4px;
}
.label-input.sidebar-label {
flex: 1;
width: auto;
min-width: 0;
}
.label-input:focus {
background: var(--bg-primary);
outline: 1px solid var(--accent);
border-radius: 2px;
}
.time-input {
background: transparent;
border: none;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
width: 85px;
padding: 2px 4px;
text-align: center;
}
.time-input:focus {
background: var(--bg-primary);
outline: 1px solid var(--accent);
border-radius: 2px;
}
.arrow {
color: var(--text-muted);
font-size: 12px;
}
/* --- Clip action buttons --- */
.clip-actions {
display: flex;
gap: 4px;
justify-content: center;
padding-top: 4px;
}
.clip-actions.inline {
padding-top: 0;
margin-left: auto;
flex-shrink: 0;
}
.action-btn {
font-size: 12px;
padding: 2px 6px;
color: var(--text-secondary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
line-height: 1.2;
}
.action-btn:hover {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
.action-btn.danger:hover {
background: var(--danger);
color: var(--bg-primary);
border-color: var(--danger);
}
/* Legacy delete button for unselected clips in bottom mode */
.delete-btn {
font-size: 14px;
padding: 2px 6px;
color: var(--text-muted);
background: transparent;
}
.delete-btn:hover {
color: var(--danger);
background: transparent;
}
</style>