Files
gui-video-clipper/docs/superpowers/plans/2026-09-22-v020-player-mode-bugfixes.md

19 KiB
Raw Blame History

v0.2.0 Player Mode Bug Fixes — 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: Fix 8 issues found during QA of the v0.2.0 Player mode: auto-hide broken, controls layout wrong, status bar visible, JKL skips 1x, speed selector broken, fullscreen broken, keyframe shortcuts broken.

Architecture: All fixes target existing files — no new files. Three main areas: App.svelte (auto-hide + keyboard), playback.ts (shuttle + fullscreen), PlayerControls.svelte (layout redesign + speed selector).

Tech Stack: Svelte 5 (runes), TypeScript, Vitest

Global Constraints

  • All imports at top of file (no inline imports).
  • Exhaustive switch with never default for TypeScript unions/enums.
  • Existing tests must continue to pass (npm test).
  • Zero errors AND zero warnings from npx svelte-check --tsconfig ./tsconfig.json.

Task 1: App.svelte — Auto-Hide + Status Bar + Keyboard Fixes

Files:

  • Modify: src/App.svelte

Interfaces:

  • Consumes: isPlayerMode (existing derived), session.isPlaying (existing store), dispatchTransport (existing function)
  • Produces: Working auto-hide for controls and toolbar, hidden status bar in Player mode, working </> keyframe shortcuts

This task fixes issues 2, 3, 4, and 8 from the spec.

  • Step 1: Fix timer variables — remove $state

In src/App.svelte, find these two lines (around lines 54-55):

  let controlsHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
  let toolbarHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);

Replace with plain let (no reactivity — these are only used in imperative timer logic):

  let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
  let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;
  • Step 2: Simplify the auto-hide $effect

Find the $effect block that checks session.isPlaying (around lines 85-96):

  $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();
    }
  });

Replace with:

  $effect(() => {
    if (!isPlayerMode) return;
    // Read play state so this re-runs on play/pause changes —
    // controls briefly show then auto-hide regardless of play state
    void session.isPlaying;
    resetControlsTimer();
  });
  • Step 3: Update resetControlsTimer — always start timer

Find resetControlsTimer (around line 152):

  function resetControlsTimer() {
    if (controlsHideTimer) clearTimeout(controlsHideTimer);
    showPlayerControls = true;
    if (session.isPlaying) {
      controlsHideTimer = setTimeout(() => {
        showPlayerControls = false;
      }, CONTROLS_HIDE_DELAY);
    }
  }

Replace with (remove the if (session.isPlaying) guard):

  function resetControlsTimer() {
    if (controlsHideTimer) clearTimeout(controlsHideTimer);
    showPlayerControls = true;
    controlsHideTimer = setTimeout(() => {
      showPlayerControls = false;
    }, CONTROLS_HIDE_DELAY);
  }
  • Step 4: Update resetToolbarTimer — always start timer

Find resetToolbarTimer (around line 162):

  function resetToolbarTimer() {
    if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
    showToolbar = true;
    if (session.isPlaying) {
      toolbarHideTimer = setTimeout(() => {
        showToolbar = false;
      }, CONTROLS_HIDE_DELAY);
    }
  }

Replace with:

  function resetToolbarTimer() {
    if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
    showToolbar = true;
    toolbarHideTimer = setTimeout(() => {
      showToolbar = false;
    }, CONTROLS_HIDE_DELAY);
  }
  • Step 5: Hide status bar in Player mode

Find the StatusBar line (around line 463):

  <StatusBar onOpenAbout={() => (showAboutDialog = true)} />

Wrap it:

  {#if !isPlayerMode}
    <StatusBar onOpenAbout={() => (showAboutDialog = true)} />
  {/if}
  • Step 6: Fix keyframe shortcuts — add < and > cases

In handleGlobalKeydown, find the , and . cases (around lines 212-219):

      case ',':
        e.preventDefault();
        dispatchTransport(e.shiftKey ? 'keyframe-back' : 'frame-back');
        break;
      case '.':
        e.preventDefault();
        dispatchTransport(e.shiftKey ? 'keyframe-forward' : 'frame-forward');
        break;

Replace with (remove the now-unreachable shiftKey ternary, add separate </> cases):

      case ',':
        e.preventDefault();
        dispatchTransport('frame-back');
        break;
      case '.':
        e.preventDefault();
        dispatchTransport('frame-forward');
        break;
      case '<':
        e.preventDefault();
        dispatchTransport('keyframe-back');
        break;
      case '>':
        e.preventDefault();
        dispatchTransport('keyframe-forward');
        break;
  • Step 7: Verify

Run: npx svelte-check --tsconfig ./tsconfig.json Expected: 0 errors, 0 warnings

Run: npm test Expected: All tests pass

  • Step 8: Commit
git add src/App.svelte
git commit -m "fix: auto-hide timers, status bar, keyframe shortcuts in Player mode"

Task 2: Playback Helpers — adjustShuttle + Fullscreen

Files:

  • Modify: src/lib/transport/playback.ts
  • Modify: tests/lib/transport/playback.test.ts
  • Modify: src/App.svelte (call sites only)

Interfaces:

  • Consumes: getVideo() (internal), document.documentElement (DOM)

  • Produces:

    • adjustShuttle(dir: 1 | -1): number — reads rate from video element, steps by 0.25, returns new rate
    • toggleFullscreen(): void — targets document.documentElement instead of video element
  • Step 1: Write updated tests for adjustShuttle

In tests/lib/transport/playback.test.ts, add this import at the top (with the existing imports):

import {
  setVideoElement,
  togglePiP,
  isPiPActive,
  toggleFullscreen,
  isFullscreenActive,
  setPlaybackRate,
  getPlaybackRate,
  adjustShuttle,
} from '$lib/transport/playback';

Add a new describe block at the end of the file:

describe('playback — adjustShuttle', () => {
  let mockVideo: HTMLVideoElement;

  beforeEach(() => {
    mockVideo = createMockVideoElement();
    setVideoElement(mockVideo);
    mockVideo.playbackRate = 1;
    mockVideo.paused = true;
    Object.defineProperty(mockVideo, 'paused', {
      value: true,
      writable: true,
      configurable: true,
    });
    mockVideo.play = vi.fn().mockResolvedValue(undefined);
  });

  it('increases rate by 0.25 when dir is 1', () => {
    const result = adjustShuttle(1);
    expect(result).toBe(1.25);
    expect(mockVideo.playbackRate).toBe(1.25);
  });

  it('decreases rate by 0.25 when dir is -1', () => {
    const result = adjustShuttle(-1);
    expect(result).toBe(0.75);
    expect(mockVideo.playbackRate).toBe(0.75);
  });

  it('reads current rate from video element, not external state', () => {
    mockVideo.playbackRate = 0.75;
    const result = adjustShuttle(1);
    expect(result).toBe(1);
  });

  it('clamps to minimum 0.25', () => {
    mockVideo.playbackRate = 0.25;
    const result = adjustShuttle(-1);
    expect(result).toBe(0.25);
  });

  it('clamps to maximum 4', () => {
    mockVideo.playbackRate = 4;
    const result = adjustShuttle(1);
    expect(result).toBe(4);
  });

  it('starts playback if paused', () => {
    adjustShuttle(1);
    expect(mockVideo.play).toHaveBeenCalled();
  });

  it('returns 1 when no video element', () => {
    setVideoElement(null);
    expect(adjustShuttle(1)).toBe(1);
  });
});
  • Step 2: Run tests to verify new tests fail

Run: npm test Expected: adjustShuttle tests fail because the current signature is adjustShuttle(dir, shuttleRate) — calling with one arg will use undefined for shuttleRate.

  • Step 3: Update adjustShuttle implementation

In src/lib/transport/playback.ts, find (around line 87):

export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
  const videoEl = getVideo();
  if (!videoEl) return shuttleRate;

  const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5));
  videoEl.playbackRate = nextRate;
  if (videoEl.paused) {
    void videoEl.play();
  }
  return nextRate;
}

Replace with:

export function adjustShuttle(dir: 1 | -1): number {
  const videoEl = getVideo();
  if (!videoEl) return 1;

  const current = videoEl.playbackRate;
  const nextRate = Math.max(0.25, Math.min(4, current + dir * 0.25));
  videoEl.playbackRate = nextRate;
  if (videoEl.paused) {
    void videoEl.play();
  }
  return nextRate;
}
  • Step 4: Update App.svelte call sites

In src/App.svelte, find the J/L key handlers (around lines 266-277):

      case 'j':
      case 'J':
        e.preventDefault();
        shuttleRate = adjustShuttle(-1, shuttleRate);
        break;

Replace with:

      case 'j':
      case 'J':
        e.preventDefault();
        shuttleRate = adjustShuttle(-1);
        break;

And:

      case 'l':
      case 'L':
        e.preventDefault();
        shuttleRate = adjustShuttle(1, shuttleRate);
        break;

Replace with:

      case 'l':
      case 'L':
        e.preventDefault();
        shuttleRate = adjustShuttle(1);
        break;
  • Step 5: Update fullscreen tests

In tests/lib/transport/playback.test.ts, find the fullscreen test (around line 89):

  it('toggleFullscreen calls requestFullscreen when not fullscreen', () => {
    toggleFullscreen();
    expect(mockVideo.requestFullscreen).toHaveBeenCalled();
  });

Replace with:

  it('toggleFullscreen calls requestFullscreen on document.documentElement', () => {
    const spy = vi.fn().mockResolvedValue(undefined);
    document.documentElement.requestFullscreen = spy;
    toggleFullscreen();
    expect(spy).toHaveBeenCalled();
  });
  • Step 6: Update toggleFullscreen implementation

In src/lib/transport/playback.ts, find toggleFullscreen (around line 118):

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 HTMLVideoElement & { webkitEnterFullscreen?: () => void }).webkitEnterFullscreen) {
      (videoEl as HTMLVideoElement & { webkitEnterFullscreen: () => void }).webkitEnterFullscreen();
    }
  }
}

Replace with:

export function toggleFullscreen(): void {
  if (document.fullscreenElement) {
    document.exitFullscreen().catch((e) => {
      console.error('Exit fullscreen failed:', e);
    });
  } else {
    const el = document.documentElement;
    if (el.requestFullscreen) {
      el.requestFullscreen().catch((e) => {
        console.error('Fullscreen request failed:', e);
      });
    } else if ((el as HTMLElement & { webkitRequestFullscreen?: () => void }).webkitRequestFullscreen) {
      (el as HTMLElement & { webkitRequestFullscreen: () => void }).webkitRequestFullscreen();
    }
  }
}
  • Step 7: Run tests

Run: npm test Expected: All tests pass (existing + new adjustShuttle tests)

Run: npx svelte-check --tsconfig ./tsconfig.json Expected: 0 errors, 0 warnings

  • Step 8: Commit
git add src/lib/transport/playback.ts tests/lib/transport/playback.test.ts src/App.svelte
git commit -m "fix: adjustShuttle reads live rate with 0.25 step, fullscreen targets documentElement"

Task 3: PlayerControls — QuickTime Layout + Speed Selector + Rate Sync

Files:

  • Modify: src/lib/components/PlayerControls.svelte

Interfaces:

  • Consumes: getPlaybackRate() from $lib/transport/playback (already imported), session from videoSession store
  • Produces: QuickTime-style floating pill layout, working speed selector popup, live rate sync from video element

This task fixes issues 1 and 6 from the spec. The speed selector stopPropagation fix (issue 6) is included in the new markup in Step 2.

  • Step 1: Add rate sync to the polling $effect

Find the polling effect (around line 132):

  // Sync PiP/fullscreen state periodically
  $effect(() => {
    const interval = setInterval(() => {
      pipActive = isPiPActive();
      fullscreenActive = isFullscreenActive();
    }, 500);
    return () => clearInterval(interval);
  });

Add currentRate sync:

  // Sync PiP/fullscreen/rate state periodically
  $effect(() => {
    const interval = setInterval(() => {
      pipActive = isPiPActive();
      fullscreenActive = isFullscreenActive();
      currentRate = getPlaybackRate();
    }, 500);
    return () => clearInterval(interval);
  });
  • Step 2: Reorganize markup — controls row first, seek bar second, timestamps third

Replace the entire content inside <div class="player-controls-panel"> (everything between the opening and closing tags of .player-controls-panel).

The current order is: seek bar → controls row.

New order with QuickTime layout:

  <div class="player-controls-panel">
    <!-- Controls row (top) -->
    <div class="controls-row">
      <!-- Left group: volume -->
      <div class="controls-group left">
        <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: playback -->
      <div class="controls-group center">
        <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>
      </div>

      <!-- Right group: settings -->
      <div class="controls-group right">
        <div class="speed-wrapper">
          <button
            class="ctrl-btn"
            onclick={(e) => { e.stopPropagation(); 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>

    <!-- Seek bar (middle) -->
    <div
      class="seek-bar"
      bind:this={seekBarEl}
      onmousedown={handleSeekBarMouseDown}
      onmousemove={handleSeekBarMouseMove}
      onmouseup={handleSeekBarMouseUp}
      onmouseleave={handleSeekBarMouseLeave}
      role="slider"
      tabindex="0"
      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>

    <!-- Timestamps (bottom) -->
    <div class="timestamps-row">
      <span class="time-display">{formatTime(session.currentTime)}</span>
      <span class="time-display">{formatTime(session.duration)}</span>
    </div>
  </div>
  • Step 3: Update CSS — floating pill positioning

Replace 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;
    z-index: 20;
  }

With:

  .player-controls-overlay {
    position: absolute;
    bottom: 24px;
    left: 15%;
    right: 15%;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.2s ease 0.1s;
    z-index: 20;
  }
  • Step 4: Update CSS — pill border-radius

Replace the .player-controls-panel border-radius:

    border-radius: 12px 12px 0 0;

With:

    border-radius: 12px;
  • Step 5: Add timestamps-row CSS

Add this new rule in the <style> block (after the .time-display rule):

  .timestamps-row {
    display: flex;
    justify-content: space-between;
    padding: 0 4px;
  }

Remove the .time-sep rule (no longer used) and update .time-display to remove white-space: nowrap if present (it's fine to keep).

Also remove the .controls-group.center old time-display styles. The center group now holds playback buttons, not time. Update center group:

  .controls-group.center {
    flex: 0 0 auto;
  }

(This is unchanged — it already handles playback buttons correctly since they have fixed sizes.)

  • Step 6: Verify

Run: npx svelte-check --tsconfig ./tsconfig.json Expected: 0 errors, 0 warnings

Run: npm test Expected: All tests pass

  • Step 7: Commit
git add src/lib/components/PlayerControls.svelte
git commit -m "fix: QuickTime-style floating controls pill, speed selector, rate sync"

Task Dependency Graph

Task 1 (App.svelte: auto-hide + status bar + keys) ──→ Task 2 (playback.ts + App.svelte call sites) ──→ Task 3 (PlayerControls layout)

Tasks are sequential because Tasks 1 and 2 both modify src/App.svelte.