diff --git a/docs/superpowers/specs/2026-09-22-v020-player-mode-bugfixes-design.md b/docs/superpowers/specs/2026-09-22-v020-player-mode-bugfixes-design.md new file mode 100644 index 0000000..bf7f223 --- /dev/null +++ b/docs/superpowers/specs/2026-09-22-v020-player-mode-bugfixes-design.md @@ -0,0 +1,264 @@ +# 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`: + +```typescript +let controlsHideTimer: ReturnType | null = null; +let toolbarHideTimer: ReturnType | 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: + +```typescript +$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: + +```typescript +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 + +`` is rendered unconditionally outside any `{#if}` block. It remains visible at the bottom of the window in Player mode. + +### Fix + +Wrap in a conditional: + +```svelte +{#if !isPlayerMode} + (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: +```typescript +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 `` 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: + +```svelte +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: + +```typescript +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 `>`: + +```typescript +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: +```typescript +case ',': + e.preventDefault(); + dispatchTransport('frame-back'); + break; +case '.': + e.preventDefault(); + dispatchTransport('frame-forward'); + break; +``` + +### Files Modified +- `src/App.svelte` — keydown handler