The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).
Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.
Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue (desktop progress bar, rAF)
- web/src/App.vue (mobile progress bar, rAF)
- web/src/views/Lyrics.vue (lyric highlight, 500ms interval)
pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).
Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Consolidated fix wave from the final whole-branch review of guest mode.
- FIX 1 (critical): gate /api/favorites mount with requireNotGuest — the
router keys off req.user.id (shared __guest__ principal), so guests could
read/write a shared favorites bucket. Added focused guest-deny tests.
- FIX 2: gate GET /api/auth/status and /api/auth/qrcode/status with
requireNotGuest so config reads no longer leak to guests.
- FIX 3: requireAuthInline in createSessionRouter now rejects guest sessions
with 401 once guest mode is disabled (mirrors createRequireAuth), so /me
stops returning guest data after an admin disables the feature.
- FIX 4: Login guest button now sits BELOW the card (auth-page flex-direction
column + guest-btn width 360px) instead of beside it.
- FIX 5: mobile mini-player transport buttons in App.vue are now per-button
gated for guests (prev/play/next/mode/volume), mirroring Player.vue.
- FIX 6: refreshed stale "gated on player.control" seek comments in Player.vue
and relabeled the now-stale quality-GET test.
npm test: 354/354 pass. npm run build: tsc + vue-tsc + vite all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Player.vue: wrap artist text in a span with ellipsis. The previous
text node sat directly inside the flex `.song-artist` container with
no overflow handling, so a long author name expanded the container
past its 240px parent and broke the bottom Player bar layout. Also
add `min-width: 0 + overflow: hidden` to `.song-info` and
`.song-artist`, and a `:title` attribute for the full text on hover.
- Settings.vue: change `resize: vertical` on the cookie textareas
to `resize: none`. The browser's resize grip rendered as a stray
black triangle at the bottom-right corner in dark theme, and
dragging it caused visual artifacts on the right edge. The
textareas keep their `rows="3"` default height.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add mobile layout (mini player, bottom tab bar, responsive navbar), new
Library view, redesigned bot dropdown with action buttons, and centralize
colors/typography/shadows into CSS variables in variables.scss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Store queue and timing state per-bot instead of globally, move bot
selector from Home page into Navbar dropdown, show bot name badge
in the player bar, and route WebSocket stateChange queues per-bot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bugs fixed:
- play-at: stop current playback before jumping to queue index
- playNext retry: use break instead of return to ensure stateChange emits
- sendVoiceData: skip if disconnecting to avoid errors during teardown
UI: player-left and lyrics button now toggle lyrics page (open/close)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add isAdvancing guard to playNext() to prevent concurrent calls from
skipping songs (trackEnd + user !next race condition)
- Replace removeAllListeners in WebSocket setup with tracked named
listeners, attach only once per bot instead of every 5 seconds
- Add .catch() to unhandled playNext() in cmdVote
- Add sessionId counter to AudioPlayer to discard stale setTimeout
callbacks after stop()+play() transitions
- Defer nulling TS3 client until disconnect() promise resolves
- Add null checks for providers in play-by-id and add-by-id endpoints
- Add PCM buffer backpressure (pause FFmpeg at 960KB, resume at 480KB)
- Change volume slider from @input to @change to avoid flooding server
- Wrap error-reporting sendTextMessage in try/catch to prevent
unhandled rejection in error handler
- Clamp elapsed getter to song duration
- Log FFmpeg stderr at debug level instead of swallowing
- Return null from prev() in Sequential mode instead of wrapping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Progress/lyrics sync:
- AudioPlayer tracks framesPlayed (ground truth: frames * 20ms + seekOffset)
- BotStatus.elapsed is the real elapsed from server
- Frontend polls GET /elapsed every 3s for ground truth
- Client interpolates between syncs (serverElapsed + timeSinceSync)
- Pause freezes at interpolated value, resume resyncs from server
- No more client-side timestamp drift
Playlist play all:
- Stops current playback before loading
- Respects play mode: random/rloop picks random first song, seq plays first
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AudioPlayer.seek() restarts FFmpeg with -ss parameter for actual seeking
- New POST /api/player/:botId/seek endpoint
- BotStatus includes seekOffset and playStartTime from server
- Frontend elapsed = seekOffset + (now - playStartedAt)
- Progress bar click now seeks to clicked position
- All timing actions use _resetTiming() helper with seekOffset
- Lyrics sync uses store.elapsed which includes seekOffset
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Progress bar reads playStartedAt directly in requestAnimationFrame loop
bypassing Vue reactivity caching for smooth 60fps updates
- Queue panel moved to sibling of player-bar (was trapped inside causing z-index issues)
- Added progress thumb dot on hover
- Click on progress shows 'seek not available' tooltip
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Playlist: add console.error in catch block so load failures are visible
- Player: add progress bar with requestAnimationFrame and time display
- Player: extract toggleQueue function with debug logging for queue panel
- WebSocket: fetch queue on stateChange when not included in event payload
- Lyrics: use CSS transform scrolling and reduce sync to 500ms to fix flicker
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add play mode cycle button (seq/loop/random/rloop) to Player bar
- Wire GET /api/player/:botId/history to query database instead of returning empty array
- Pass database instance from server.ts to createPlayerRouter
- Add back navigation button to Playlist, Lyrics, History, Search, and Settings views
- SongCard already had dblclick-to-play (no change needed)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>