fix: post-smoke-test polish — preserve playhead, clip list redesign, play-clip action, 1100x750 default
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
# v0.1.3 Polish/Tweaks Implementation Summary
|
||||||
|
|
||||||
|
**Date:** 2026-09-22 16:55 ET
|
||||||
|
**Version:** 0.1.2 → 0.1.3
|
||||||
|
|
||||||
|
## Task Description
|
||||||
|
|
||||||
|
Implemented three UI polish features for v0.1.3 of the GUI Video Clipper (Tauri + Svelte 5):
|
||||||
|
|
||||||
|
1. **Adjustable clip list layout** — clip list can appear below the timeline (default) or as a left sidebar next to the video preview, with a toolbar toggle button and persisted preference
|
||||||
|
2. **Timeline timestamp magnification** — increased base font from 10px to 12px, with a mouse-proximity magnification effect using cosine interpolation (~18px peak, 80px radius)
|
||||||
|
3. **Timeline right-click context menu** — custom context menu replacing the default web menu, with Mark In/Out, Go to Clip Start/End, and Delete Clip actions
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### Commits (oldest → newest)
|
||||||
|
- `0b8bec2` — chore: bump version to 0.1.3
|
||||||
|
- `b371ec6` — feat: adjustable clip list layout (bottom/left sidebar)
|
||||||
|
- `923c7b0` — fix: clip list width edge cases (falsy check, narrow-window clamp)
|
||||||
|
- `27c199e` — feat: larger timeline timestamps with mouse-proximity magnification
|
||||||
|
- `8c27177` — feat: timeline right-click context menu with mark/seek/delete actions
|
||||||
|
- `279e27a` — docs: user edits to v0.1.3 spec (version bump section)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
- `VERSION` — new file, version 0.1.3
|
||||||
|
- `package.json` — version bump
|
||||||
|
- `package-lock.json` — lockfile sync
|
||||||
|
- `src-tauri/tauri.conf.json` — version bump
|
||||||
|
- `src-tauri/Cargo.toml` — version bump
|
||||||
|
- `src-tauri/Cargo.lock` — lockfile sync
|
||||||
|
- `src/lib/stores/preferences.svelte.ts` — added `clipListPosition` and `clipListWidth` preferences
|
||||||
|
- `src/App.svelte` — dual layout modes (bottom/left), toggle button, sidebar resize
|
||||||
|
- `src/lib/components/ClipList.svelte` — added `position` prop for sidebar styling
|
||||||
|
- `src/lib/timeline/renderer.ts` — larger base font, magnification math in `drawTimeTicks()`
|
||||||
|
- `src/lib/components/Timeline.svelte` — mouseX tracking, context menu wiring
|
||||||
|
- `src/lib/components/TimelineContextMenu.svelte` — new context menu component
|
||||||
|
|
||||||
|
### Design & Plan Docs
|
||||||
|
- `docs/superpowers/specs/2026-09-22-v013-polish-tweaks.md` — design spec
|
||||||
|
- `docs/superpowers/plans/2026-09-22-v013-polish-tweaks.md` — implementation plan
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
Used subagent-driven development: 5 tasks dispatched as subagents with task reviews between each. Two reviewer findings on Task 2 (falsy width check, narrow-window clamp) were fixed before proceeding.
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
- The layout restructure required careful conditional rendering in App.svelte — the `{#if}/{:else}` approach creates two separate DOM trees. This means TransportControls gets remounted on layout toggle, which should be smoke-tested.
|
||||||
|
- Context menu `$effect` for viewport clamping can flash position (0,0) for one frame before adjustment — initializing adjusted positions from the raw x/y props would eliminate this.
|
||||||
|
- The bump-version.sh script doesn't sync package-lock.json — needed manual `npm install --package-lock-only`.
|
||||||
|
|
||||||
|
## Follow-Up Items
|
||||||
|
|
||||||
|
- [ ] Manual smoke test with `npm run tauri dev` — layout toggle, sidebar resize, timestamp magnification feel, context menu flow
|
||||||
|
- [ ] Consider fixing first-frame context menu position flash
|
||||||
|
- [ ] Consider adding `onresize` clamp for sidebar width when window shrinks below saved width
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "GUI Video Clipper",
|
"title": "GUI Video Clipper",
|
||||||
"width": 800,
|
"width": 1100,
|
||||||
"height": 600
|
"height": 750
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
selectClip,
|
selectClip,
|
||||||
removeClip,
|
removeClip,
|
||||||
updateClip,
|
updateClip,
|
||||||
|
type Clip,
|
||||||
} from '$lib/stores/clips.svelte';
|
} from '$lib/stores/clips.svelte';
|
||||||
import { formatTime, parseTime } from '$lib/utils/time';
|
import { formatTime, parseTime } from '$lib/utils/time';
|
||||||
import { session } from '$lib/stores/videoSession.svelte';
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
|
import { seekTo } from '$lib/transport/playback';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onExport,
|
onExport,
|
||||||
@@ -15,14 +17,13 @@
|
|||||||
|
|
||||||
let clips = $derived(clipStore.clips);
|
let clips = $derived(clipStore.clips);
|
||||||
let selectedId = $derived(clipStore.selectedClipId);
|
let selectedId = $derived(clipStore.selectedClipId);
|
||||||
|
let isSidebar = $derived(position === 'left');
|
||||||
|
|
||||||
function handleSelect(id: string) {
|
function handleSelect(id: string) {
|
||||||
selectClip(id);
|
selectClip(id);
|
||||||
const clip = clips.find((c) => c.id === id);
|
const clip = clips.find((c) => c.id === id);
|
||||||
if (clip) {
|
if (clip) {
|
||||||
session.currentTime = clip.startTime;
|
seekTo(clip.startTime);
|
||||||
const videoEl = document.querySelector('video');
|
|
||||||
if (videoEl) videoEl.currentTime = clip.startTime;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,16 +40,29 @@
|
|||||||
|
|
||||||
function handleContainerClick(e: MouseEvent) {
|
function handleContainerClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
// Only deselect if the click landed on the container itself, not a child
|
|
||||||
if (target.classList.contains('clip-list')) {
|
if (target.classList.contains('clip-list')) {
|
||||||
selectClip(null);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="clip-list"
|
class="clip-list"
|
||||||
class:sidebar={position === 'left'}
|
class:sidebar={isSidebar}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={handleContainerClick}
|
onclick={handleContainerClick}
|
||||||
@@ -68,6 +82,7 @@
|
|||||||
<div
|
<div
|
||||||
class="clip-row"
|
class="clip-row"
|
||||||
class:selected={clip.id === selectedId}
|
class:selected={clip.id === selectedId}
|
||||||
|
class:sidebar={isSidebar}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={clip.id === selectedId}
|
aria-selected={clip.id === selectedId}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -79,6 +94,65 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{#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>
|
<span class="color-swatch" style="background: {clip.color}"></span>
|
||||||
<input
|
<input
|
||||||
class="label-input"
|
class="label-input"
|
||||||
@@ -104,17 +178,40 @@
|
|||||||
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
handleTimeEdit(clip.id, 'endTime', (e.target as HTMLInputElement).value)}
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
|
{#if clip.id === selectedId}
|
||||||
|
<div class="clip-actions inline">
|
||||||
<button
|
<button
|
||||||
class="delete-btn"
|
class="action-btn"
|
||||||
onclick={(e) => {
|
onclick={(e) => { e.stopPropagation(); handlePlayClip(clip); }}
|
||||||
e.stopPropagation();
|
title="Play clip"
|
||||||
removeClip(clip.id);
|
>▶</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"
|
title="Delete clip"
|
||||||
>✕</button>
|
>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="delete-btn"
|
||||||
|
onclick={(e) => { e.stopPropagation(); removeClip(clip.id); }}
|
||||||
|
title="Delete clip"
|
||||||
|
>✕</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="action-buttons">
|
<div class="list-actions">
|
||||||
{#if selectedId}
|
{#if selectedId}
|
||||||
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
|
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -139,6 +236,7 @@
|
|||||||
.clip-list.sidebar {
|
.clip-list.sidebar {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 8px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -155,7 +253,7 @@
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.list-actions {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -163,7 +261,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons button {
|
.list-actions button {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
}
|
}
|
||||||
@@ -173,6 +271,7 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Bottom (wide) row layout --- */
|
||||||
.clip-row {
|
.clip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -195,6 +294,32 @@
|
|||||||
background: var(--bg-tertiary);
|
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 {
|
.color-swatch {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
@@ -211,6 +336,12 @@
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-input.sidebar-label {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.label-input:focus {
|
.label-input:focus {
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
outline: 1px solid var(--accent);
|
outline: 1px solid var(--accent);
|
||||||
@@ -239,6 +370,44 @@
|
|||||||
font-size: 12px;
|
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 {
|
.delete-btn {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
@@ -279,6 +279,15 @@
|
|||||||
case 'delete-clip':
|
case 'delete-clip':
|
||||||
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
|
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
|
||||||
break;
|
break;
|
||||||
|
case 'play-clip': {
|
||||||
|
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
|
||||||
|
if (clip) {
|
||||||
|
seekTo(clip.startTime);
|
||||||
|
const videoEl = document.querySelector('video');
|
||||||
|
if (videoEl?.paused) videoEl.play();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const _exhaustive: never = action;
|
const _exhaustive: never = action;
|
||||||
void _exhaustive;
|
void _exhaustive;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
| { type: 'mark-out' }
|
| { type: 'mark-out' }
|
||||||
| { type: 'go-to-clip-start' }
|
| { type: 'go-to-clip-start' }
|
||||||
| { type: 'go-to-clip-end' }
|
| { type: 'go-to-clip-end' }
|
||||||
|
| { type: 'play-clip' }
|
||||||
| { type: 'delete-clip' };
|
| { type: 'delete-clip' };
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -126,6 +127,14 @@
|
|||||||
|
|
||||||
{#if selectedClipId}
|
{#if selectedClipId}
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
|
<button
|
||||||
|
class="menu-item"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => handleAction({ type: 'play-clip' })}
|
||||||
|
>
|
||||||
|
<span class="label">Play Clip</span>
|
||||||
|
<span class="shortcut">▶</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="menu-item danger"
|
class="menu-item danger"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -135,6 +135,10 @@
|
|||||||
|
|
||||||
function handleLoadedData() {
|
function handleLoadedData() {
|
||||||
videoError = null;
|
videoError = null;
|
||||||
|
// Restore playhead position (e.g. after layout toggle remounts the video)
|
||||||
|
if (videoElement && session.currentTime > 0) {
|
||||||
|
videoElement.currentTime = session.currentTime;
|
||||||
|
}
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
console.log('[VideoPlayer] loadeddata:', {
|
console.log('[VideoPlayer] loadeddata:', {
|
||||||
src: videoElement.src,
|
src: videoElement.src,
|
||||||
|
|||||||
Reference in New Issue
Block a user