docs: v0.2.0 Player mode bugfixes design spec

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 19:39:51 -04:00
parent 711f18ffe5
commit 0e84e66cf7

View File

@@ -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<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:
```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
`<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:
```svelte
{#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:
```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 `<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:
```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