Files
gui-video-clipper/docs/superpowers/plans/2026-09-22-v020-player-mode.md
2026-09-22 18:58:00 -04:00

1975 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# v0.2.0 Player Mode Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a dedicated "Player" mode alongside the existing "Clipper" mode, with floating controls, auto-hiding UI, PiP, fullscreen, and smooth FLIP transitions between modes.
**Architecture:** Single `App.svelte` with conditional rendering based on a persisted `appMode` preference. The `<video>` element never unmounts during mode switches. Three new Svelte components (`PlayerControls`, `PlayerTimeline`, `SpeedSelector`) handle Player-mode-specific UI. Existing components are hidden/shown via CSS grid changes and Svelte conditionals.
**Tech Stack:** Svelte 5 (runes, `$state`/`$derived`/`$effect`/`$props`), Tauri v2, TypeScript, Vitest (jsdom), Canvas 2D (waveform rendering), Web PiP API, Fullscreen API.
## Global Constraints
- Version target: `0.2.0`
- macOS only (WKWebView); PiP uses `HTMLVideoElement.requestPictureInPicture()`, fullscreen uses `requestFullscreen()` with `webkitEnterFullscreen()` fallback.
- No Rust backend changes required.
- All imports at top of file (no inline imports).
- Exhaustive `switch` statements over unions/enums use a `never` check in the `default` case.
- Existing tests must continue to pass (`npm test`).
- The `<video>` element must never be destroyed during mode switches — playhead, playback state, volume, speed, clips, and all session state must be preserved.
---
### Task 1: Preferences Store — `appMode` Field
**Files:**
- Modify: `src/lib/stores/preferences.svelte.ts`
- Test: `tests/lib/stores/preferences.test.ts` (create)
**Interfaces:**
- Consumes: nothing new
- Produces: `preferences.appMode: 'clipper' | 'player'`, `setAppMode(mode: 'clipper' | 'player'): Promise<void>`
- [ ] **Step 1: Write the failing test**
Create `tests/lib/stores/preferences.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the Tauri store before importing the module under test
vi.mock('@tauri-apps/plugin-store', () => {
const mockStore = {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
save: vi.fn().mockResolvedValue(undefined),
};
return {
load: vi.fn().mockResolvedValue(mockStore),
Store: vi.fn(),
};
});
describe('preferences store — appMode', () => {
beforeEach(() => {
vi.resetModules();
});
it('defaults appMode to clipper', async () => {
const { preferences } = await import('$lib/stores/preferences.svelte');
expect(preferences.appMode).toBe('clipper');
});
it('setAppMode updates appMode to player', async () => {
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
await setAppMode('player');
expect(preferences.appMode).toBe('player');
});
it('setAppMode updates appMode back to clipper', async () => {
const { preferences, setAppMode } = await import('$lib/stores/preferences.svelte');
await setAppMode('player');
await setAppMode('clipper');
expect(preferences.appMode).toBe('clipper');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/lib/stores/preferences.test.ts`
Expected: FAIL — `preferences.appMode` is undefined (field doesn't exist yet) and `setAppMode` is not exported.
- [ ] **Step 3: Add `appMode` to the Preferences interface and defaults**
In `src/lib/stores/preferences.svelte.ts`, add `appMode` to the `Preferences` interface:
```typescript
export interface Preferences {
outputDirectory: string;
cookieSource: CookieSource;
captionSettings: CaptionSettings;
clipListPosition: 'bottom' | 'left';
clipListWidth: number;
appMode: 'clipper' | 'player';
}
```
Update `DEFAULT_PREFERENCES`:
```typescript
const DEFAULT_PREFERENCES: Preferences = {
outputDirectory: '~/',
cookieSource: { type: 'browser', browser: 'firefox' },
captionSettings: { ...DEFAULT_CAPTION_SETTINGS },
clipListPosition: 'bottom',
clipListWidth: 220,
appMode: 'clipper',
};
```
- [ ] **Step 4: Add persistence — load and save `appMode`**
In `loadPreferences()`, after the `clipListWidth` loading block, add:
```typescript
const savedAppMode = await store.get<'clipper' | 'player'>('appMode');
if (savedAppMode) preferences.appMode = savedAppMode;
```
In `savePreferences()`, after the `clipListWidth` save, add:
```typescript
await store.set('appMode', preferences.appMode);
```
- [ ] **Step 5: Add `setAppMode` helper**
At the end of `src/lib/stores/preferences.svelte.ts`, add:
```typescript
export async function setAppMode(mode: 'clipper' | 'player'): Promise<void> {
preferences.appMode = mode;
await savePreferences();
}
```
- [ ] **Step 6: Run test to verify it passes**
Run: `npx vitest run tests/lib/stores/preferences.test.ts`
Expected: PASS (3 tests)
- [ ] **Step 7: Run full test suite to verify no regressions**
Run: `npm test`
Expected: All existing tests pass.
- [ ] **Step 8: Commit**
```bash
git add src/lib/stores/preferences.svelte.ts tests/lib/stores/preferences.test.ts
git commit -m "feat: add appMode preference (clipper/player) with persistence"
```
---
### Task 2: Playback Helpers — PiP, Fullscreen, PlaybackRate
**Files:**
- Modify: `src/lib/transport/playback.ts`
- Test: `tests/lib/transport/playback.test.ts` (create)
**Interfaces:**
- Consumes: existing `getVideo()` (module-private), `setVideoElement()`
- Produces: `togglePiP(): void`, `isPiPActive(): boolean`, `toggleFullscreen(): void`, `isFullscreenActive(): boolean`, `setPlaybackRate(rate: number): void`, `getPlaybackRate(): number`
- [ ] **Step 1: Write the failing tests**
Create `tests/lib/transport/playback.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock the videoSession store (playback.ts imports it)
vi.mock('$lib/stores/videoSession.svelte', () => ({
session: {
duration: 120,
currentTime: 0,
fps: 30,
isPlaying: false,
keyframePositions: [],
},
}));
import {
setVideoElement,
togglePiP,
isPiPActive,
toggleFullscreen,
isFullscreenActive,
setPlaybackRate,
getPlaybackRate,
} from '$lib/transport/playback';
function createMockVideoElement(): HTMLVideoElement {
const el = document.createElement('video');
// PiP
(el as any).requestPictureInPicture = vi.fn().mockResolvedValue({});
// Fullscreen
el.requestFullscreen = vi.fn().mockResolvedValue(undefined);
return el;
}
describe('playback — PiP helpers', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
// Reset PiP state
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
writable: true,
configurable: true,
});
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
writable: true,
configurable: true,
});
});
it('isPiPActive returns false when no PiP element', () => {
expect(isPiPActive()).toBe(false);
});
it('togglePiP calls requestPictureInPicture', () => {
togglePiP();
expect((mockVideo as any).requestPictureInPicture).toHaveBeenCalled();
});
it('isPiPActive returns true when PiP element exists', () => {
Object.defineProperty(document, 'pictureInPictureElement', {
value: mockVideo,
writable: true,
configurable: true,
});
expect(isPiPActive()).toBe(true);
});
});
describe('playback — fullscreen helpers', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
Object.defineProperty(document, 'fullscreenElement', {
value: null,
writable: true,
configurable: true,
});
});
it('isFullscreenActive returns false when not fullscreen', () => {
expect(isFullscreenActive()).toBe(false);
});
it('toggleFullscreen calls requestFullscreen when not fullscreen', () => {
toggleFullscreen();
expect(mockVideo.requestFullscreen).toHaveBeenCalled();
});
it('isFullscreenActive returns true when fullscreen element exists', () => {
Object.defineProperty(document, 'fullscreenElement', {
value: mockVideo,
writable: true,
configurable: true,
});
expect(isFullscreenActive()).toBe(true);
});
});
describe('playback — playback rate helpers', () => {
let mockVideo: HTMLVideoElement;
beforeEach(() => {
mockVideo = createMockVideoElement();
setVideoElement(mockVideo);
});
it('getPlaybackRate returns 1 by default', () => {
expect(getPlaybackRate()).toBe(1);
});
it('setPlaybackRate changes the video playback rate', () => {
setPlaybackRate(1.5);
expect(mockVideo.playbackRate).toBe(1.5);
});
it('getPlaybackRate returns the current rate', () => {
setPlaybackRate(2);
expect(getPlaybackRate()).toBe(2);
});
it('getPlaybackRate returns 1 when no video element', () => {
setVideoElement(null);
expect(getPlaybackRate()).toBe(1);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run tests/lib/transport/playback.test.ts`
Expected: FAIL — `togglePiP`, `isPiPActive`, `toggleFullscreen`, `isFullscreenActive`, `setPlaybackRate`, `getPlaybackRate` are not exported.
- [ ] **Step 3: Implement the helpers**
Add the following to the end of `src/lib/transport/playback.ts` (before the `runTransportAction` function):
```typescript
export function togglePiP(): void {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch((e) => {
console.error('Exit PiP failed:', e);
});
} else {
const videoEl = getVideo();
if (videoEl && document.pictureInPictureEnabled) {
videoEl.requestPictureInPicture().catch((e) => {
console.error('PiP request failed:', e);
});
}
}
}
export function isPiPActive(): boolean {
return !!document.pictureInPictureElement;
}
export function toggleFullscreen(): void {
if (document.fullscreenElement) {
document.exitFullscreen().catch((e) => {
console.error('Exit fullscreen failed:', e);
});
} else {
const videoEl = getVideo();
if (!videoEl) return;
if (videoEl.requestFullscreen) {
videoEl.requestFullscreen().catch((e) => {
console.error('Fullscreen request failed:', e);
});
} else if ((videoEl as any).webkitEnterFullscreen) {
(videoEl as any).webkitEnterFullscreen();
}
}
}
export function isFullscreenActive(): boolean {
return !!document.fullscreenElement;
}
export function setPlaybackRate(rate: number): void {
const videoEl = getVideo();
if (videoEl) {
videoEl.playbackRate = rate;
}
}
export function getPlaybackRate(): number {
return getVideo()?.playbackRate ?? 1;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run tests/lib/transport/playback.test.ts`
Expected: PASS (all tests)
- [ ] **Step 5: Run full test suite**
Run: `npm test`
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add src/lib/transport/playback.ts tests/lib/transport/playback.test.ts
git commit -m "feat: add PiP, fullscreen, and playbackRate helpers to playback module"
```
---
### Task 3: SpeedSelector Component
**Files:**
- Create: `src/lib/components/SpeedSelector.svelte`
- Test: `tests/lib/components/SpeedSelector.test.ts` (create)
**Interfaces:**
- Consumes: nothing
- Produces: `SpeedSelector` component with props `{ currentRate: number; onSelect: (rate: number) => void; onClose: () => void }`
- [ ] **Step 1: Write the failing test**
Create `tests/lib/components/SpeedSelector.test.ts`:
```typescript
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import SpeedSelector from '$lib/components/SpeedSelector.svelte';
describe('SpeedSelector', () => {
it('renders all speed options', () => {
const { getByText } = render(SpeedSelector, {
props: { currentRate: 1, onSelect: vi.fn(), onClose: vi.fn() },
});
expect(getByText('0.5×')).toBeTruthy();
expect(getByText('0.75×')).toBeTruthy();
expect(getByText('1×')).toBeTruthy();
expect(getByText('1.25×')).toBeTruthy();
expect(getByText('1.5×')).toBeTruthy();
expect(getByText('2×')).toBeTruthy();
});
it('highlights the current rate', () => {
const { container } = render(SpeedSelector, {
props: { currentRate: 1.5, onSelect: vi.fn(), onClose: vi.fn() },
});
const activeBtn = container.querySelector('.speed-option.active');
expect(activeBtn).toBeTruthy();
expect(activeBtn!.textContent).toContain('1.5×');
});
it('calls onSelect when an option is clicked', async () => {
const onSelect = vi.fn();
const { getByText } = render(SpeedSelector, {
props: { currentRate: 1, onSelect, onClose: vi.fn() },
});
await fireEvent.click(getByText('2×'));
expect(onSelect).toHaveBeenCalledWith(2);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/lib/components/SpeedSelector.test.ts`
Expected: FAIL — module not found.
- [ ] **Step 3: Create the SpeedSelector component**
Create `src/lib/components/SpeedSelector.svelte`:
```svelte
<script lang="ts">
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
let {
currentRate,
onSelect,
onClose,
}: {
currentRate: number;
onSelect: (rate: number) => void;
onClose: () => void;
} = $props();
function handleSelect(rate: number) {
onSelect(rate);
onClose();
}
function handleWindowClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.speed-selector')) {
onClose();
}
}
</script>
<svelte:window onclick={handleWindowClick} />
<div class="speed-selector" role="menu">
{#each SPEED_OPTIONS as rate}
<button
class="speed-option"
class:active={Math.abs(currentRate - rate) < 0.01}
role="menuitem"
onclick={() => handleSelect(rate)}
>
<span class="check">{Math.abs(currentRate - rate) < 0.01 ? '✓' : ''}</span>
{rate}×
</button>
{/each}
</div>
<style>
.speed-selector {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
border-radius: 8px;
padding: 4px;
display: flex;
flex-direction: column;
min-width: 80px;
z-index: 50;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.speed-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
color: var(--text-primary, #cdd6f4);
font-size: 13px;
cursor: pointer;
border-radius: 4px;
white-space: nowrap;
}
.speed-option:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--accent, #89b4fa);
}
.speed-option.active {
color: var(--accent, #89b4fa);
font-weight: 600;
}
.check {
width: 14px;
font-size: 11px;
text-align: center;
}
</style>
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/lib/components/SpeedSelector.test.ts`
Expected: PASS (3 tests)
- [ ] **Step 5: Commit**
```bash
git add src/lib/components/SpeedSelector.svelte tests/lib/components/SpeedSelector.test.ts
git commit -m "feat: add SpeedSelector popup component"
```
---
### Task 4: PlayerControls Component
**Files:**
- Create: `src/lib/components/PlayerControls.svelte`
**Interfaces:**
- Consumes: `session` from `$lib/stores/videoSession.svelte`, `seekTo`, `seekBy`, `togglePlayPause`, `setVolume`, `setMuted`, `getVolume`, `getMuted`, `togglePiP`, `isPiPActive`, `toggleFullscreen`, `isFullscreenActive`, `setPlaybackRate`, `getPlaybackRate` from `$lib/transport/playback`, `formatTime` from `$lib/utils/time`, `SpeedSelector` from `$lib/components/SpeedSelector.svelte`
- Produces: `PlayerControls` component with props `{ visible: boolean; captionsEnabled: boolean; hasCaptions: boolean; onToggleCaptions: () => void; onOpenCaptionSettings: () => void }`
- [ ] **Step 1: Create PlayerControls.svelte**
Create `src/lib/components/PlayerControls.svelte`:
```svelte
<script lang="ts">
import SpeedSelector from '$lib/components/SpeedSelector.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
seekTo,
seekBy,
togglePlayPause,
setVolume,
setMuted,
getVolume,
getMuted,
togglePiP,
isPiPActive,
toggleFullscreen,
isFullscreenActive,
setPlaybackRate,
getPlaybackRate,
} from '$lib/transport/playback';
import { formatTime } from '$lib/utils/time';
let {
visible,
captionsEnabled,
hasCaptions,
onToggleCaptions,
onOpenCaptionSettings,
}: {
visible: boolean;
captionsEnabled: boolean;
hasCaptions: boolean;
onToggleCaptions: () => void;
onOpenCaptionSettings: () => void;
} = $props();
let showSpeedSelector = $state(false);
let volume = $state(getVolume());
let isMuted = $state(getMuted());
let currentRate = $state(getPlaybackRate());
let pipActive = $state(false);
let fullscreenActive = $state(false);
// Seek bar state
let seekBarEl = $state<HTMLDivElement | null>(null);
let isSeeking = $state(false);
let hoverTime = $state<number | null>(null);
let hoverX = $state(0);
let progress = $derived(
session.duration > 0 ? session.currentTime / session.duration : 0
);
function handleSeekBarMouseDown(e: MouseEvent) {
isSeeking = true;
seekFromMouseEvent(e);
}
function handleSeekBarMouseMove(e: MouseEvent) {
if (!seekBarEl) return;
const rect = seekBarEl.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
hoverX = x;
hoverTime = (x / rect.width) * session.duration;
if (isSeeking) {
seekFromMouseEvent(e);
}
}
function handleSeekBarMouseUp() {
isSeeking = false;
}
function handleSeekBarMouseLeave() {
hoverTime = null;
if (isSeeking) {
isSeeking = false;
}
}
function seekFromMouseEvent(e: MouseEvent) {
if (!seekBarEl) return;
const rect = seekBarEl.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const time = (x / rect.width) * session.duration;
seekTo(time);
}
function handleVolumeChange(e: Event) {
const val = parseFloat((e.target as HTMLInputElement).value);
volume = val;
if (val > 0 && isMuted) {
isMuted = false;
}
setVolume(val);
if (val > 0) {
setMuted(false);
}
}
function handleToggleMute() {
isMuted = !isMuted;
setMuted(isMuted);
}
function handleSpeedSelect(rate: number) {
currentRate = rate;
setPlaybackRate(rate);
showSpeedSelector = false;
}
function handlePiP() {
togglePiP();
// State is updated via event listeners in VideoPlayer
setTimeout(() => {
pipActive = isPiPActive();
}, 100);
}
function handleFullscreen() {
toggleFullscreen();
setTimeout(() => {
fullscreenActive = isFullscreenActive();
}, 100);
}
function handleCCContextMenu(e: MouseEvent) {
e.preventDefault();
onOpenCaptionSettings();
}
// Sync PiP/fullscreen state periodically
$effect(() => {
const interval = setInterval(() => {
pipActive = isPiPActive();
fullscreenActive = isFullscreenActive();
}, 500);
return () => clearInterval(interval);
});
</script>
<svelte:window
onmousemove={(e) => { if (isSeeking) seekFromMouseEvent(e); }}
onmouseup={() => { isSeeking = false; }}
/>
<div
class="player-controls-overlay"
class:visible
role="toolbar"
aria-label="Player controls"
>
<div class="player-controls-panel">
<!-- Seek bar -->
<div
class="seek-bar"
bind:this={seekBarEl}
onmousedown={handleSeekBarMouseDown}
onmousemove={handleSeekBarMouseMove}
onmouseup={handleSeekBarMouseUp}
onmouseleave={handleSeekBarMouseLeave}
role="slider"
aria-label="Seek"
aria-valuenow={session.currentTime}
aria-valuemin={0}
aria-valuemax={session.duration}
>
<div class="seek-track">
<div class="seek-fill" style="width: {progress * 100}%"></div>
<div class="seek-thumb" style="left: {progress * 100}%"></div>
</div>
{#if hoverTime !== null}
<div class="seek-tooltip" style="left: {hoverX}px">
{formatTime(hoverTime)}
</div>
{/if}
</div>
<!-- Controls row -->
<div class="controls-row">
<!-- Left group: playback -->
<div class="controls-group left">
<button
class="ctrl-btn"
onclick={() => seekBy(-10)}
title="Skip back 10s"
type="button"
>⏪</button>
<button
class="ctrl-btn play-btn"
onclick={togglePlayPause}
title={session.isPlaying ? 'Pause' : 'Play'}
type="button"
>{session.isPlaying ? '❚❚' : '▶'}</button>
<button
class="ctrl-btn"
onclick={() => seekBy(10)}
title="Skip forward 10s"
type="button"
>⏩</button>
<!-- Volume -->
<button
class="ctrl-btn vol-btn"
onclick={handleToggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
type="button"
>
{#if isMuted || volume === 0}🔇{:else if volume < 0.5}🔉{:else}🔊{/if}
</button>
<input
type="range"
class="vol-slider"
min="0"
max="1"
step="0.05"
value={isMuted ? 0 : volume}
oninput={handleVolumeChange}
/>
</div>
<!-- Center group: time -->
<div class="controls-group center">
<span class="time-display">
{formatTime(session.currentTime)}
<span class="time-sep">/</span>
{formatTime(session.duration)}
</span>
</div>
<!-- Right group: settings -->
<div class="controls-group right">
<div class="speed-wrapper">
<button
class="ctrl-btn"
onclick={() => { showSpeedSelector = !showSpeedSelector; }}
title="Playback speed"
type="button"
>{currentRate}×</button>
{#if showSpeedSelector}
<SpeedSelector
{currentRate}
onSelect={handleSpeedSelect}
onClose={() => { showSpeedSelector = false; }}
/>
{/if}
</div>
{#if hasCaptions}
<button
class="ctrl-btn"
class:active={captionsEnabled}
onclick={onToggleCaptions}
oncontextmenu={handleCCContextMenu}
title={captionsEnabled ? 'Hide captions' : 'Show captions'}
type="button"
>CC</button>
{/if}
<button
class="ctrl-btn"
class:active={pipActive}
onclick={handlePiP}
title="Picture-in-Picture"
type="button"
>⧉</button>
<button
class="ctrl-btn"
class:active={fullscreenActive}
onclick={handleFullscreen}
title={fullscreenActive ? 'Exit fullscreen' : 'Fullscreen'}
type="button"
>{fullscreenActive ? '⤓' : '⛶'}</button>
</div>
</div>
</div>
</div>
<style>
.player-controls-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 20;
}
.player-controls-overlay.visible {
opacity: 1;
}
.player-controls-panel {
pointer-events: auto;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
border-radius: 12px 12px 0 0;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
/* --- Seek bar --- */
.seek-bar {
width: 100%;
height: 20px;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
}
.seek-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
position: relative;
transition: height 0.15s ease;
}
.seek-bar:hover .seek-track {
height: 6px;
}
.seek-fill {
height: 100%;
background: var(--accent, #89b4fa);
border-radius: 2px;
}
.seek-thumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
background: var(--accent, #89b4fa);
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.15s ease;
}
.seek-bar:hover .seek-thumb {
opacity: 1;
}
.seek-tooltip {
position: absolute;
bottom: 100%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: var(--text-primary, #cdd6f4);
font-family: var(--font-mono, monospace);
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
white-space: nowrap;
pointer-events: none;
margin-bottom: 4px;
}
/* --- Controls row --- */
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.controls-group {
display: flex;
align-items: center;
gap: 4px;
}
.controls-group.left {
flex: 1;
justify-content: flex-start;
}
.controls-group.center {
flex: 0 0 auto;
}
.controls-group.right {
flex: 1;
justify-content: flex-end;
}
/* --- Buttons --- */
.ctrl-btn {
background: transparent;
border: none;
color: var(--text-primary, #cdd6f4);
font-size: 16px;
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
min-width: 32px;
min-height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.ctrl-btn:hover {
color: var(--accent, #89b4fa);
background: transparent;
}
.ctrl-btn.active {
color: var(--accent, #89b4fa);
}
.ctrl-btn.play-btn {
font-size: 20px;
min-width: 40px;
}
/* --- Volume --- */
.vol-slider {
width: 70px;
height: 4px;
accent-color: var(--accent, #89b4fa);
cursor: pointer;
}
/* --- Time --- */
.time-display {
font-family: var(--font-mono, monospace);
font-size: 13px;
color: var(--text-secondary, #a6adc8);
white-space: nowrap;
}
.time-sep {
color: var(--text-muted, #6c7086);
margin: 0 3px;
}
/* --- Speed wrapper --- */
.speed-wrapper {
position: relative;
}
</style>
```
- [ ] **Step 2: Verify the component compiles**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: No new errors related to `PlayerControls.svelte`. (Pre-existing warnings are fine.)
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/PlayerControls.svelte
git commit -m "feat: add PlayerControls floating overlay component"
```
---
### Task 5: PlayerTimeline Component
**Files:**
- Create: `src/lib/components/PlayerTimeline.svelte`
**Interfaces:**
- Consumes: `session` from `$lib/stores/videoSession.svelte`, `drawWaveform` and `WaveformData` from `$lib/timeline/waveformRenderer`, `TimelineState`, `timeToX`, `drawPlayhead` from `$lib/timeline/renderer`, `seekTo` from `$lib/transport/playback`
- Produces: `PlayerTimeline` component with props `{ visible: boolean }`
- [ ] **Step 1: Create PlayerTimeline.svelte**
Create `src/lib/components/PlayerTimeline.svelte`:
```svelte
<script lang="ts">
import { session } from '$lib/stores/videoSession.svelte';
import { drawWaveform, type WaveformData } from '$lib/timeline/waveformRenderer';
import { type TimelineState, timeToX, drawPlayhead } from '$lib/timeline/renderer';
import { seekTo } from '$lib/transport/playback';
let { visible }: { visible: boolean } = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let containerEl = $state<HTMLDivElement | null>(null);
let pendingDraw = 0;
const TIMELINE_HEIGHT = 50;
let timelineState = $state<TimelineState>({
visibleStart: 0,
visibleEnd: 60,
zoom: 1,
width: 600,
height: TIMELINE_HEIGHT,
});
let waveformData = $derived<WaveformData>({
tiers: session.waveformTiers,
});
function requestDraw() {
if (!pendingDraw) {
pendingDraw = requestAnimationFrame(() => {
drawMainCanvas();
pendingDraw = 0;
});
}
}
function drawMainCanvas() {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.save();
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const { width } = timelineState;
ctx.clearRect(0, 0, width, TIMELINE_HEIGHT);
// Background
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, width, TIMELINE_HEIGHT);
// Draw major time ticks
drawSimpleTimeTicks(ctx, timelineState, session.duration);
// Draw waveform
if (waveformData.tiers) {
drawWaveform(ctx, timelineState, waveformData, session.duration, 0, TIMELINE_HEIGHT - 16);
}
// Draw playhead
drawPlayhead(ctx, timelineState, session.currentTime);
ctx.restore();
}
function drawSimpleTimeTicks(
ctx: CanvasRenderingContext2D,
state: TimelineState,
duration: number
) {
if (duration <= 0) return;
const range = state.visibleEnd - state.visibleStart;
const tickInterval = getTickInterval(range, state.width);
const startTick = Math.floor(state.visibleStart / tickInterval) * tickInterval;
const baselineY = TIMELINE_HEIGHT - 2;
ctx.textAlign = 'center';
ctx.font = '10px -apple-system, sans-serif';
ctx.fillStyle = '#6c7086';
for (let t = startTick; t <= state.visibleEnd; t += tickInterval) {
if (t < 0 || t > duration) continue;
const x = timeToX(t, state);
ctx.strokeStyle = '#45475a';
ctx.beginPath();
ctx.moveTo(x, TIMELINE_HEIGHT - 14);
ctx.lineTo(x, TIMELINE_HEIGHT - 8);
ctx.stroke();
const m = Math.floor(t / 60);
const s = Math.floor(t % 60);
ctx.fillText(`${m}:${String(s).padStart(2, '0')}`, x, baselineY);
}
}
function getTickInterval(visibleRange: number, width: number): number {
const minPixelsPerTick = 80;
const maxTicks = width / minPixelsPerTick;
const rawInterval = visibleRange / maxTicks;
const intervals = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
for (const interval of intervals) {
if (interval >= rawInterval) return interval;
}
return 600;
}
// Sync timeline state with session duration
$effect(() => {
if (session.duration > 0) {
timelineState.visibleStart = 0;
timelineState.visibleEnd = session.duration;
timelineState.zoom = 1;
}
});
// Resize observer
$effect(() => {
if (!containerEl) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
timelineState.width = entry.contentRect.width;
if (canvas) {
canvas.width = entry.contentRect.width * window.devicePixelRatio;
canvas.height = TIMELINE_HEIGHT * window.devicePixelRatio;
canvas.style.width = `${entry.contentRect.width}px`;
canvas.style.height = `${TIMELINE_HEIGHT}px`;
}
}
});
observer.observe(containerEl);
return () => observer.disconnect();
});
// Redraw on state changes
$effect(() => {
void session.currentTime;
void session.duration;
void session.waveformTiers;
void timelineState.visibleStart;
void timelineState.visibleEnd;
void timelineState.width;
void canvas;
requestDraw();
});
function handleClick(e: MouseEvent) {
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const range = timelineState.visibleEnd - timelineState.visibleStart;
const time = timelineState.visibleStart + (x / timelineState.width) * range;
seekTo(Math.max(0, Math.min(time, session.duration)));
}
</script>
<div
class="player-timeline"
class:visible
bind:this={containerEl}
>
<canvas
bind:this={canvas}
onclick={handleClick}
></canvas>
</div>
<style>
.player-timeline {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 50px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 15;
pointer-events: none;
opacity: 0;
transform: translateY(100%);
transition: opacity 0.2s ease, transform 0.2s ease;
cursor: crosshair;
}
.player-timeline.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
```
- [ ] **Step 2: Verify the component compiles**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: No new errors related to `PlayerTimeline.svelte`.
- [ ] **Step 3: Commit**
```bash
git add src/lib/components/PlayerTimeline.svelte
git commit -m "feat: add PlayerTimeline simplified waveform component"
```
---
### Task 6: App.svelte — Mode Switching, Layout & Auto-Hide
**Files:**
- Modify: `src/App.svelte`
- Modify: `src/app.css`
**Interfaces:**
- Consumes: `preferences.appMode` and `setAppMode` from `$lib/stores/preferences.svelte`, `PlayerControls` from `$lib/components/PlayerControls.svelte`, `PlayerTimeline` from `$lib/components/PlayerTimeline.svelte`, `toggleFullscreen` from `$lib/transport/playback`, `session` from `$lib/stores/videoSession.svelte`
- Produces: Complete mode-switching UI with auto-hide behavior, keyboard shortcuts, mode toggle button
- [ ] **Step 1: Add imports to App.svelte**
Add these imports at the top of the `<script>` block in `src/App.svelte`, after the existing imports:
```typescript
import PlayerControls from '$lib/components/PlayerControls.svelte';
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
import { toggleFullscreen } from '$lib/transport/playback';
```
Also update the preferences import line to include `setAppMode`:
```typescript
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
```
- [ ] **Step 2: Add Player mode state variables**
Add after the existing `let windowWidth` declaration:
```typescript
let isPlayerMode = $derived(preferences.appMode === 'player');
// Auto-hide state for Player mode
let showPlayerControls = $state(true);
let showToolbar = $state(true);
let showPlayerTimeline = $state(false);
let controlsHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
let toolbarHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
// Caption state for PlayerControls
let captionsEnabled = $state(true);
const CONTROLS_HIDE_DELAY = 2500;
const TOOLBAR_PROXIMITY = 50;
const TIMELINE_PROXIMITY = 80;
```
- [ ] **Step 3: Add auto-hide logic**
Add these functions after the existing `handleSidebarResizeEnd` function:
```typescript
function resetControlsTimer() {
if (controlsHideTimer) clearTimeout(controlsHideTimer);
showPlayerControls = true;
if (session.isPlaying) {
controlsHideTimer = setTimeout(() => {
showPlayerControls = false;
}, CONTROLS_HIDE_DELAY);
}
}
function resetToolbarTimer() {
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
showToolbar = true;
if (session.isPlaying) {
toolbarHideTimer = setTimeout(() => {
showToolbar = false;
}, CONTROLS_HIDE_DELAY);
}
}
function handlePlayerMouseMove(e: MouseEvent) {
if (!isPlayerMode) return;
// Controls: show on any mouse movement over the video area
resetControlsTimer();
// Toolbar: show when near top edge
if (e.clientY < TOOLBAR_PROXIMITY) {
resetToolbarTimer();
}
// Timeline: show when near bottom edge
const windowH = window.innerHeight;
showPlayerTimeline = e.clientY > windowH - TIMELINE_PROXIMITY;
}
function toggleMode() {
const next = preferences.appMode === 'clipper' ? 'player' : 'clipper';
setAppMode(next);
// When entering Player mode, show controls initially
if (next === 'player') {
showPlayerControls = true;
showToolbar = true;
resetControlsTimer();
}
}
function handleToggleCaptions() {
captionsEnabled = !captionsEnabled;
}
```
- [ ] **Step 4: Add an effect to keep controls visible when paused**
Add this effect after the existing `$effect` blocks:
```typescript
$effect(() => {
if (!isPlayerMode) return;
if (!session.isPlaying) {
// Video paused — show everything
showPlayerControls = true;
showToolbar = true;
if (controlsHideTimer) clearTimeout(controlsHideTimer);
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
} else {
// Video playing — start hide timers
resetControlsTimer();
}
});
```
- [ ] **Step 5: Update keyboard handler**
In `handleGlobalKeydown`, add these cases to the `switch` statement (before the `default:` case):
```typescript
case 'p':
case 'P':
e.preventDefault();
setAppMode('player');
if (preferences.appMode !== 'player') {
showPlayerControls = true;
showToolbar = true;
resetControlsTimer();
}
break;
case 'c':
case 'C':
e.preventDefault();
setAppMode('clipper');
break;
case 'f':
case 'F':
if (isPlayerMode) {
e.preventDefault();
toggleFullscreen();
}
break;
```
Also, wrap the Clipper-only shortcuts (`i`/`o`, `Delete`/`Backspace`, `Cmd+E`) with an `if (!isPlayerMode)` guard. Specifically, modify the `i`/`I` case:
```typescript
case 'i':
case 'I':
if (!isPlayerMode) {
e.preventDefault();
dispatchTransport('mark-in');
}
break;
case 'o':
case 'O':
if (!isPlayerMode) {
e.preventDefault();
dispatchTransport('mark-out');
}
break;
```
And the `Delete`/`Backspace` case:
```typescript
case 'Delete':
case 'Backspace': {
if (!isPlayerMode) {
e.preventDefault();
const selectedId = getSelectedClipId();
if (selectedId) removeClip(selectedId);
}
break;
}
```
And the `Cmd+E` case:
```typescript
case 'e':
if (e.metaKey && !isPlayerMode) {
e.preventDefault();
showExportDialog = true;
}
break;
```
- [ ] **Step 6: Update the window mousemove handler**
In the `<svelte:window>` element, update the `onmousemove` handler to include player mouse tracking:
```svelte
<svelte:window
onkeydown={handleGlobalKeydown}
onmousemove={(e) => {
handleResizeMove(e);
handleSidebarResizeMove(e);
handlePlayerMouseMove(e);
}}
onmouseup={() => {
handleResizeEnd();
handleSidebarResizeEnd();
}}
onresize={() => {
windowWidth = window.innerWidth;
}}
/>
```
- [ ] **Step 7: Add the mode toggle button to the toolbar**
In the toolbar `<header>`, add the mode toggle button between `<UrlInput />` and the layout button:
```svelte
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
<UrlInput />
<button
class="mode-btn"
onclick={toggleMode}
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
>
{isPlayerMode ? 'Player' : 'Clipper'}
</button>
{#if !isPlayerMode}
<button
class="layout-btn"
onclick={toggleClipListPosition}
title={preferences.clipListPosition === 'bottom'
? 'Move clip list to sidebar'
: 'Move clip list to bottom'}
>
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button>
{/if}
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
</header>
```
- [ ] **Step 8: Update the main content area for Player mode**
The `<video>` element must survive mode switches (no remount). To achieve this, keep `<VideoPlayer>` always-mounted in a single location and conditionally render the surrounding chrome. Replace the existing `<main>` section with this structure:
```svelte
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
{#if !isPlayerMode}
<!-- Sidebar clip list: visible only in left mode -->
<div class="sidebar-area" class:hidden={!isLeft}>
<ClipList onExport={() => (showExportDialog = true)} position="left" />
</div>
<div
class="sidebar-handle"
class:hidden={!isLeft}
class:active={isSidebarResizing}
role="slider"
aria-orientation="vertical"
aria-label="Resize clip list sidebar"
aria-valuenow={preferences.clipListWidth}
aria-valuemin={150}
aria-valuemax={Math.round(windowWidth * 0.4)}
tabindex="0"
onmousedown={handleSidebarResizeStart}
onkeydown={(e) => {
const maxWidth = Math.round(windowWidth * 0.4);
const minWidth = Math.min(150, maxWidth);
if (e.key === 'ArrowRight') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
setClipListWidth(
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
);
}
}}
></div>
{/if}
<!-- Video area: always mounted, never destroyed -->
<div class="video-area" class:player-video={isPlayerMode}>
<VideoPlayer />
{#if isPlayerMode}
<PlayerControls
visible={showPlayerControls}
{captionsEnabled}
hasCaptions={session.hasCaptions || false}
onToggleCaptions={handleToggleCaptions}
onOpenCaptionSettings={() => {}}
/>
<PlayerTimeline visible={showPlayerTimeline || !session.isPlaying} />
{:else}
<TransportControls bind:this={transportControls} />
{/if}
</div>
{#if !isPlayerMode}
<!-- Timeline section: always full width -->
<div class="lower-section">
<div
class="resize-handle"
class:active={isResizing}
role="slider"
aria-orientation="horizontal"
aria-label="Resize timeline"
aria-valuenow={timelineHeight}
aria-valuemin={40}
aria-valuemax={500}
tabindex="0"
onmousedown={handleResizeStart}
onkeydown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
timelineHeight = Math.min(500, timelineHeight + 10);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
timelineHeight = Math.max(40, timelineHeight - 10);
}
}}
></div>
<div class="timeline-pane" style="height: {timelineHeight}px">
<Timeline />
</div>
</div>
<!-- Bottom clip list: visible only in bottom mode -->
<div class="bottom-cliplist" class:hidden={isLeft}>
<ClipList onExport={() => (showExportDialog = true)} />
</div>
{/if}
</main>
```
Key point: `<VideoPlayer>` is in the `.video-area` div which is **always rendered** (not inside any `{#if}` block). Only the siblings (transport controls, player overlays) are conditional. This guarantees the `<video>` element survives mode switches.
- [ ] **Step 9: Add CSS styles**
Add to the `<style>` block in `src/App.svelte`:
```css
/* --- Mode toggle button --- */
.mode-btn {
font-size: 12px;
padding: 4px 10px;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.3px;
}
/* --- Player mode layout --- */
.content.player-mode {
grid-template-areas: "video";
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.player-video {
position: relative;
overflow: hidden;
}
/* --- Toolbar auto-hide --- */
.toolbar.auto-hide {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 30;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toolbar.toolbar-hidden {
opacity: 0;
transform: translateY(-100%);
pointer-events: none;
}
.toolbar.toolbar-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
```
Add to `src/app.css`:
```css
/* Player mode: app shell becomes relative for overlay positioning */
.app-shell.player-mode {
position: relative;
}
```
- [ ] **Step 10: Verify compilation**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: No new errors. (Address any type issues found.)
- [ ] **Step 11: Run full test suite**
Run: `npm test`
Expected: All existing tests pass.
- [ ] **Step 12: Commit**
```bash
git add src/App.svelte src/app.css
git commit -m "feat: add Player/Clipper mode switching with auto-hide UI"
```
---
### Task 7: VideoPlayer.svelte — Conditional CC & PiP/Fullscreen Events
**Files:**
- Modify: `src/lib/components/VideoPlayer.svelte`
**Interfaces:**
- Consumes: `preferences.appMode` from `$lib/stores/preferences.svelte`
- Produces: PiP/fullscreen event listeners on the `<video>` element, CC toggle rendered only in Clipper mode. Exposes `captionsEnabled` as a bindable prop for PlayerControls integration.
- [ ] **Step 1: Add preferences import**
In `VideoPlayer.svelte`, add to the imports:
```typescript
import { preferences } from '$lib/stores/preferences.svelte';
```
- [ ] **Step 2: Add derived mode check**
After the existing state declarations, add:
```typescript
let isPlayerMode = $derived(preferences.appMode === 'player');
```
- [ ] **Step 3: Make captionsEnabled a bindable prop**
Replace the local `captionsEnabled` state with a bindable prop with a default:
```typescript
let {
captionsEnabled = $bindable(true),
}: {
captionsEnabled?: boolean;
} = $props();
```
Remove the old `let captionsEnabled = $state(true);` line.
- [ ] **Step 4: Add click-to-toggle-play and double-click-to-fullscreen (Player mode)**
Add these handlers:
```typescript
import { togglePlayPause as playbackTogglePlayPause, toggleFullscreen } from '$lib/transport/playback';
function handleVideoClick() {
if (isPlayerMode) {
playbackTogglePlayPause();
}
}
let clickTimer: ReturnType<typeof setTimeout> | null = null;
function handleVideoClickWithDelay(e: MouseEvent) {
if (!isPlayerMode) return;
// Distinguish single click from double click
if (clickTimer) {
clearTimeout(clickTimer);
clickTimer = null;
// Double click
toggleFullscreen();
} else {
clickTimer = setTimeout(() => {
clickTimer = null;
playbackTogglePlayPause();
}, 250);
}
}
```
Add `onclick={handleVideoClickWithDelay}` to the `<video>` element.
- [ ] **Step 5: Conditionally render CC controls**
Wrap the CC controls section so it only renders in Clipper mode:
```svelte
{#if !isPlayerMode && parsedCues.length > 0}
<div class="cc-controls">
<button
class="cc-toggle"
class:active={captionsEnabled}
onclick={toggleCaptions}
title={captionsEnabled ? 'Hide captions' : 'Show captions'}
>CC</button>
<button
class="cc-settings-btn"
onclick={toggleCaptionSettings}
title="Caption settings"
>⚙</button>
</div>
{/if}
{#if !isPlayerMode && showCaptionSettings}
<CaptionSettingsPanel onClose={() => (showCaptionSettings = false)} />
{/if}
```
- [ ] **Step 6: Verify compilation**
Run: `npx svelte-check --tsconfig ./tsconfig.json`
Expected: No new errors.
- [ ] **Step 7: Run full test suite**
Run: `npm test`
Expected: All tests pass.
- [ ] **Step 8: Commit**
```bash
git add src/lib/components/VideoPlayer.svelte
git commit -m "feat: conditional CC rendering and click-to-play in Player mode"
```
---
### Task 8: FLIP Transitions for Mode Switching
**Files:**
- Modify: `src/App.svelte`
- Modify: `src/app.css`
**Interfaces:**
- Consumes: `preferences.appMode` from `$lib/stores/preferences.svelte`
- Produces: Smooth animated transitions when switching between Clipper and Player modes
- [ ] **Step 1: Add CSS grid transition support**
In `src/app.css`, add a transition to the `.content` class so grid row/column changes animate:
```css
.content {
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
}
```
- [ ] **Step 2: Add Svelte transition directives**
In `src/App.svelte`, import transition helpers at the top of the script:
```typescript
import { fly, fade } from 'svelte/transition';
```
Apply `transition:fly` to the elements that appear/disappear during mode switch. In the Clipper-mode branch, add transitions to the transport controls, timeline, and clip list sections:
On the `TransportControls` wrapper (the `.video-area` div that contains it): This doesn't need a transition since it's inside the conditional — Svelte handles mount/unmount.
Instead, apply transitions to the Clipper-mode-only elements. Wrap the lower section and clip lists:
```svelte
<!-- Inside the Clipper mode branch -->
<div class="lower-section" transition:fly={{ y: 50, duration: 250 }}>
<!-- ... timeline content ... -->
</div>
<div class="bottom-cliplist" class:hidden={isLeft} transition:fly={{ y: 30, duration: 250 }}>
<!-- ... clip list ... -->
</div>
```
And on the Clipper-mode transport controls line within the video-area:
```svelte
{#if !isPlayerMode}
<div transition:fade={{ duration: 200 }}>
<TransportControls bind:this={transportControls} />
</div>
{/if}
```
- [ ] **Step 3: Add toolbar slide transition**
The toolbar auto-hide already uses CSS transitions (added in Task 6). Ensure the toolbar transition is smooth on mode switch too. The CSS classes `.toolbar-hidden` and `.toolbar-visible` with `transition: opacity 0.2s ease, transform 0.2s ease` handle this.
For the initial mode switch animation (Clipper→Player), the toolbar starts visible and then the auto-hide CSS kicks in. No additional code needed.
- [ ] **Step 4: Add player controls staggered fade-in**
In `PlayerControls.svelte`, add a slight delay to the controls appearance on initial mount. Update the `.player-controls-overlay` CSS:
```css
.player-controls-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease 0.1s; /* 100ms delay for staggered appearance */
z-index: 20;
}
.player-controls-overlay.visible {
opacity: 1;
transition-delay: 0.1s;
}
```
- [ ] **Step 5: Verify visual behavior**
Run: `npm run tauri dev`
Expected behavior:
- Pressing `P` switches to Player mode with the clipper chrome sliding away and the video expanding smoothly.
- Pressing `C` switches back with the chrome sliding back in.
- Total transition feels ~300ms, no janky layout shifts.
- [ ] **Step 6: Run full test suite**
Run: `npm test`
Expected: All tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/App.svelte src/app.css src/lib/components/PlayerControls.svelte
git commit -m "feat: add FLIP transitions for Clipper/Player mode switching"
```
---
### Task 9: Version Bump to 0.2.0
**Files:**
- Modify: `VERSION`
- Modify: `package.json`
- Modify: `src-tauri/tauri.conf.json`
- Modify: `src-tauri/Cargo.toml`
**Interfaces:**
- Consumes: nothing
- Produces: All version files updated to `0.2.0`
- [ ] **Step 1: Run the bump-version script**
```bash
bash scripts/bump-version.sh 0.2.0
```
Expected output:
```
Bumping to version 0.2.0
✓ package.json
✓ src-tauri/Cargo.toml
✓ src-tauri/tauri.conf.json
✓ src-tauri/Cargo.lock
Done — all manifests set to 0.2.0
```
- [ ] **Step 2: Verify the version was bumped correctly**
```bash
cat VERSION
# Expected: 0.2.0
node -e "console.log(require('./package.json').version)"
# Expected: 0.2.0
grep '^version' src-tauri/Cargo.toml
# Expected: version = "0.2.0"
```
- [ ] **Step 3: Run full test suite one last time**
Run: `npm test`
Expected: All tests pass.
- [ ] **Step 4: Commit**
```bash
git add VERSION package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json src-tauri/Cargo.lock
git commit -m "chore: bump version to 0.2.0"
```
---
## Task Dependency Graph
```
Task 1 (preferences) ─────────────────┐
├──→ Task 6 (App.svelte layout)
Task 2 (playback helpers) ──┬──────────┤
│ │
Task 3 (SpeedSelector) ─────┤ │
│ │
├──→ Task 4 (PlayerControls)
│ ├──→ Task 8 (transitions)
Task 5 (PlayerTimeline) ────┘──→ Task 6 │
│ │
Task 7 (VideoPlayer) ────────┘
│
Task 9 (version bump)
```
Tasks 1, 2, 3 can be done in parallel. Task 5 is independent of 3 and 4. Task 9 is done last.