53 KiB
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 usesrequestFullscreen()withwebkitEnterFullscreen()fallback. - No Rust backend changes required.
- All imports at top of file (no inline imports).
- Exhaustive
switchstatements over unions/enums use anevercheck in thedefaultcase. - 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:
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
appModeto the Preferences interface and defaults
In src/lib/stores/preferences.svelte.ts, add appMode to the Preferences interface:
export interface Preferences {
outputDirectory: string;
cookieSource: CookieSource;
captionSettings: CaptionSettings;
clipListPosition: 'bottom' | 'left';
clipListWidth: number;
appMode: 'clipper' | 'player';
}
Update DEFAULT_PREFERENCES:
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:
const savedAppMode = await store.get<'clipper' | 'player'>('appMode');
if (savedAppMode) preferences.appMode = savedAppMode;
In savePreferences(), after the clipListWidth save, add:
await store.set('appMode', preferences.appMode);
- Step 5: Add
setAppModehelper
At the end of src/lib/stores/preferences.svelte.ts, add:
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
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:
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):
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
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:
SpeedSelectorcomponent with props{ currentRate: number; onSelect: (rate: number) => void; onClose: () => void } -
Step 1: Write the failing test
Create tests/lib/components/SpeedSelector.test.ts:
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:
<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
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:
sessionfrom$lib/stores/videoSession.svelte,seekTo,seekBy,togglePlayPause,setVolume,setMuted,getVolume,getMuted,togglePiP,isPiPActive,toggleFullscreen,isFullscreenActive,setPlaybackRate,getPlaybackRatefrom$lib/transport/playback,formatTimefrom$lib/utils/time,SpeedSelectorfrom$lib/components/SpeedSelector.svelte -
Produces:
PlayerControlscomponent with props{ visible: boolean; captionsEnabled: boolean; hasCaptions: boolean; onToggleCaptions: () => void; onOpenCaptionSettings: () => void } -
Step 1: Create PlayerControls.svelte
Create src/lib/components/PlayerControls.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
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:
sessionfrom$lib/stores/videoSession.svelte,drawWaveformandWaveformDatafrom$lib/timeline/waveformRenderer,TimelineState,timeToX,drawPlayheadfrom$lib/timeline/renderer,seekTofrom$lib/transport/playback -
Produces:
PlayerTimelinecomponent with props{ visible: boolean } -
Step 1: Create PlayerTimeline.svelte
Create src/lib/components/PlayerTimeline.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
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.appModeandsetAppModefrom$lib/stores/preferences.svelte,PlayerControlsfrom$lib/components/PlayerControls.svelte,PlayerTimelinefrom$lib/components/PlayerTimeline.svelte,toggleFullscreenfrom$lib/transport/playback,sessionfrom$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:
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:
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:
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:
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:
$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):
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:
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:
case 'Delete':
case 'Backspace': {
if (!isPlayerMode) {
e.preventDefault();
const selectedId = getSelectedClipId();
if (selectedId) removeClip(selectedId);
}
break;
}
And the Cmd+E case:
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: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:
<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:
<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:
/* --- 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:
/* 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
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.appModefrom$lib/stores/preferences.svelte -
Produces: PiP/fullscreen event listeners on the
<video>element, CC toggle rendered only in Clipper mode. ExposescaptionsEnabledas a bindable prop for PlayerControls integration. -
Step 1: Add preferences import
In VideoPlayer.svelte, add to the imports:
import { preferences } from '$lib/stores/preferences.svelte';
- Step 2: Add derived mode check
After the existing state declarations, add:
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:
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:
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:
{#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
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.appModefrom$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:
.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:
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:
<!-- 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:
{#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:
.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
Pswitches to Player mode with the clipper chrome sliding away and the video expanding smoothly. -
Pressing
Cswitches 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
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 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
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
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.