# 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