1975 lines
53 KiB
Markdown
1975 lines
53 KiB
Markdown
# 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.
|