Files
gui-video-clipper/docs/superpowers/specs/2026-09-22-v020-player-mode-bugfixes-design.md
2026-09-22 19:39:51 -04:00

9.2 KiB
Raw Blame History

v0.2.0 Player Mode Bug Fixes — Design Spec

Date: 2026-09-22 Scope: 8 issues found during QA of the v0.2.0 Player mode implementation


1. QuickTime-Style Floating Controls Layout

Problem

The current PlayerControls overlay is pinned full-width to the bottom edge of the video, obscuring the waveform timeline. It should look and behave like QuickTime Player: a compact, centered, rounded pill floating above the bottom edge.

Design

New layout structure:

┌─────────────────────────────────────────────────────────┐
│  🔊━━━━   ⏪  ❚❚  ⏩   1×  CC  ⧉  ⛶                   │  controls row
│  ──────────────────────────────━━━━━━━━━━━━──────────── │  seek bar
│  00:06                                          02:58   │  timestamps
└─────────────────────────────────────────────────────────┘

Controls row (top):

  • Left group: mute button + volume slider
  • Center group: skip −10s, play/pause, skip +10s
  • Right group: speed selector, CC toggle, PiP, fullscreen

Seek bar (middle): full pill width, thin track that expands on hover, thumb visible on hover.

Timestamps (bottom): current time left-aligned, duration right-aligned.

CSS changes to .player-controls-overlay:

  • Remove: bottom: 0; left: 0; right: 0
  • Add: bottom: 24px; left: 15%; right: 15% (centered ~70% width, floating above bottom edge)
  • The overlay itself becomes the positioning container; pointer-events remain none on the overlay, auto on the panel.

CSS changes to .player-controls-panel:

  • Change: border-radius: 12px 12px 0 0 → border-radius: 12px (rounded on all corners)

Markup reorder: Controls row first, seek bar second, timestamps third (currently: seek bar, controls row).

Files Modified

  • src/lib/components/PlayerControls.svelte — markup reorder + CSS

2. Auto-Hide Reactive Loop Fix

Problem

controlsHideTimer and toolbarHideTimer are declared with $state. The $effect that calls resetControlsTimer() reads controlsHideTimer (via clearTimeout), then writes a new timer ID. Svelte 5 tracks the read as a dependency, re-runs the effect, clears the just-set timer, sets a new one — infinite loop. The timeout callback never fires. Controls and toolbar never auto-hide.

Fix

Change both timer variables from $state to plain let:

let controlsHideTimer: ReturnType<typeof setTimeout> | null = null;
let toolbarHideTimer: ReturnType<typeof setTimeout> | null = null;

Nothing in the template reads these values. They are only used in imperative timer logic and do not need reactivity.

Files Modified

  • src/App.svelte — 2 variable declarations

3. Controls Visible When Paused

Problem

The current $effect forces showPlayerControls = true and showToolbar = true whenever !session.isPlaying. This means controls permanently show when paused. QuickTime hides controls when paused after the same timeout.

Fix

Remove the special paused branch. Both playing and paused states use the same timer-based auto-hide. The $effect simplifies to:

$effect(() => {
  if (!isPlayerMode) return;
  // Explicitly read play state so Svelte tracks it as a dependency —
  // controls briefly show on every play/pause toggle
  void session.isPlaying;
  resetControlsTimer();
});

resetControlsTimer also changes — remove the if (session.isPlaying) guard so the timer always starts:

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

Same change for resetToolbarTimer — always start the timer.

Mouse movement resets the timer regardless of play state. The only time controls are forced visible without a timer is during the initial mode switch into Player mode (handled by toggleMode()).

Files Modified

  • src/App.svelte — $effect body, resetControlsTimer / resetToolbarTimer logic

4. Status Bar Hidden in Player Mode

Problem

<StatusBar> is rendered unconditionally outside any {#if} block. It remains visible at the bottom of the window in Player mode.

Fix

Wrap in a conditional:

{#if !isPlayerMode}
  <StatusBar onOpenAbout={() => (showAboutDialog = true)} />
{/if}

Files Modified

  • src/App.svelte — template

5. JKL Shuttle Skips 1.0x

Problem

Two sub-issues:

  1. shuttleRate in App.svelte and the actual video playbackRate are disconnected. The speed selector sets videoEl.playbackRate directly, but shuttleRate stays stale. Next JKL press uses the stale value.
  2. Step size of 0.5 doesn't align with speed selector values (0.5, 0.75, 1, 1.25, 1.5, 2). If the rate is ever on the 0.25-offset grid (0.75, 1.25), pressing J/L cycles between those values and 1.0 is unreachable.

Fix

Change adjustShuttle to:

  • Read the current rate from videoEl.playbackRate (source of truth) instead of taking an external shuttleRate parameter
  • Use step size 0.25 to align with the speed selector grid
  • Return the new rate so callers can update display state

New signature:

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;
}

App.svelte call sites update from adjustShuttle(-1, shuttleRate) to adjustShuttle(-1). The returned value updates shuttleRate for any display that reads it.

PlayerControls syncs currentRate from the live video rate. The existing 500ms polling $effect (for PiP/fullscreen state) is extended to also sync currentRate = getPlaybackRate().

Files Modified

  • src/lib/transport/playback.ts — adjustShuttle signature + body
  • src/App.svelte — call sites
  • src/lib/components/PlayerControls.svelte — add currentRate to polling effect
  • tests/lib/transport/playback.test.ts — update test for new signature

6. Speed Selector Immediately Closes

Problem

SpeedSelector uses <svelte:window onclick={handleWindowClick}> to detect click-outside. The same click that toggles showSpeedSelector to true bubbles to the window, where handleWindowClick sees the target is outside .speed-selector and calls onClose(). The popup renders for one frame then immediately closes.

Fix

Add stopPropagation on the toggle button click in PlayerControls:

onclick={(e) => { e.stopPropagation(); showSpeedSelector = !showSpeedSelector; }}

This prevents the opening click from reaching the window-level close handler.

Files Modified

  • src/lib/components/PlayerControls.svelte — toggle button onclick

7. Fullscreen Broken in WKWebView

Problem

toggleFullscreen() calls videoEl.requestFullscreen(), which doesn't work in Tauri's WKWebView. The standard Fullscreen API on child elements is not supported in WKWebView.

Fix

Target document.documentElement instead of the video element:

export function toggleFullscreen(): void {
  if (document.fullscreenElement) {
    document.exitFullscreen().catch(console.error);
  } else {
    const el = document.documentElement;
    if (el.requestFullscreen) {
      el.requestFullscreen().catch(console.error);
    } else if ((el as any).webkitRequestFullscreen) {
      (el as any).webkitRequestFullscreen();
    }
  }
}

This makes the entire webview fullscreen. In Player mode the video already fills the viewport, so the result is the same as video-only fullscreen. The webkitRequestFullscreen fallback covers older WebKit versions.

Files Modified

  • src/lib/transport/playback.ts — toggleFullscreen body
  • tests/lib/transport/playback.test.ts — update mocks to use document.documentElement

8. Shift+, / Shift+. Produce Wrong Key Values

Problem

On US keyboards, Shift+, produces < and Shift+. produces >. The keydown handler matches on , and ., which don't match when Shift is held. Keyframe skip shortcuts never fire.

Fix

Add explicit cases for < and >:

case '<':
  e.preventDefault();
  dispatchTransport('keyframe-back');
  break;
case '>':
  e.preventDefault();
  dispatchTransport('keyframe-forward');
  break;

These go after the existing , and . cases (which handle frame-step and already check e.shiftKey — but that path is unreachable). The ,/. cases can drop the e.shiftKey ternary since those keys only fire without Shift.

Updated ,/. cases become:

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

Files Modified

  • src/App.svelte — keydown handler