docs: v0.2.0 Player mode bugfixes design spec
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user