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

53 KiB
Raw Blame History

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:

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:

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 setAppMode helper

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: 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:

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: 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:

<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: 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:

<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.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:

  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.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:

  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.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:

.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 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
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.