Skip to content

Commit 409b170

Browse files
fix(queue): load all library pages on double-click, detect playback stalls near track end
Double-click play from library browser only enqueued tracks from loaded virtual-scroll pages. Add _loadAllPages() call before building the queue so all filtered tracks are included regardless of scroll position. Symphonia MP3 decode errors near track end prevented the rodio sink from draining, so sink.empty() never returned true and track-ended never fired. Add StallDetector that monitors playback position in the audio thread polling loop and emits track-ended when progress stalls within the final 3 seconds for 4+ consecutive ticks. Closes TASK-332
1 parent 5b19e8e commit 409b170

4 files changed

Lines changed: 377 additions & 1 deletion

File tree

app/frontend/__tests__/queue-builder.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,58 @@ describe('handleDoubleClickPlay', () => {
254254
});
255255
});
256256

257+
describe('handleDoubleClickPlay - paginated library regression (task-332)', () => {
258+
// Regression: double-clicking a track in a paginated library only queued
259+
// tracks from loaded pages. When the user scrolled to page 5 but pages 1-4
260+
// weren't loaded, filteredTracks was a sparse subset and the globalIndex
261+
// was out of bounds, causing fallback to single-track playback.
262+
263+
beforeEach(() => {
264+
vi.clearAllMocks();
265+
});
266+
267+
it('falls back to single-track when allTracks has gaps from partial page loading', async () => {
268+
// Simulate: 20 total tracks across 4 pages of 5, only page 0 and page 2 loaded
269+
const page0 = makeTracks(['A', 'B', 'C', 'D', 'E']);
270+
const page2 = makeTracks(['K', 'L', 'M', 'N', 'O']);
271+
// filteredTracks only has 10 items from the 2 loaded pages
272+
const partialTracks = [...page0, ...page2];
273+
274+
const ctx = createMockCtx();
275+
// Track at globalIndex 10 (page 2, offset 0) - but partialTracks only has 10 items
276+
// so index 10 is out of bounds
277+
await handleDoubleClickPlay(ctx, page2[0], partialTracks, 10, 'test');
278+
279+
// Bug: falls back to single-track playback instead of queuing full library
280+
expect(ctx.player.playTrack).toHaveBeenCalledWith(page2[0]);
281+
expect(queueApi.playContext).not.toHaveBeenCalled();
282+
});
283+
284+
it('queues full library when all pages are loaded before calling', async () => {
285+
// After fix: _loadAllPages() is called first, so allTracks has all 20 items
286+
const allTracks = makeTracks([
287+
'A', 'B', 'C', 'D', 'E', // page 0
288+
'F', 'G', 'H', 'I', 'J', // page 1
289+
'K', 'L', 'M', 'N', 'O', // page 2
290+
'P', 'Q', 'R', 'S', 'T', // page 3
291+
]);
292+
293+
const result = makePlayContextResult(allTracks, 10);
294+
queueApi.playContext.mockResolvedValue(result);
295+
296+
const ctx = createMockCtx();
297+
// globalIndex 10 is now valid (track K at page 2, offset 0)
298+
await handleDoubleClickPlay(ctx, allTracks[10], allTracks, 10, 'test');
299+
300+
expect(queueApi.playContext).toHaveBeenCalledWith(
301+
allTracks.map((t) => t.id),
302+
10,
303+
false,
304+
);
305+
expect(ctx.player.playTrack).not.toHaveBeenCalled();
306+
});
307+
});
308+
257309
describe('player.updateTrackState', () => {
258310
// Test the updateTrackState method logic in isolation
259311
// (simulating what the real player store does)

app/frontend/js/components/library-browser.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,12 @@ export function createLibraryBrowser(Alpine) {
450450
},
451451

452452
async handleDoubleClick(track, index) {
453+
// For paginated sections, load all pages so the full library is
454+
// enqueued as context (not just the pages the user has scrolled through).
455+
if (this.library._isPaginated() && !this.library._allPagesLoaded) {
456+
await this.library._loadAllPages();
457+
}
458+
453459
await handleDoubleClickPlay(
454460
this,
455461
track,
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
---
2+
id: TASK-332
3+
title: >-
4+
Queue broken: double-click doesn't enqueue full library, track-ended doesn't
5+
auto-advance
6+
status: Done
7+
assignee: []
8+
created_date: '2026-04-14 15:23'
9+
updated_date: '2026-04-14 16:08'
10+
labels:
11+
- bug
12+
- queue
13+
- playback
14+
- frontend
15+
dependencies: []
16+
references:
17+
- app/frontend/js/utils/queue-builder.js
18+
- app/frontend/js/components/library-browser.js
19+
- app/frontend/js/stores/library.js
20+
- app/frontend/js/stores/player.js
21+
- app/frontend/js/stores/queue.js
22+
- crates/mt-tauri/src/audio/engine.rs
23+
- crates/mt-tauri/src/commands/audio.rs
24+
priority: high
25+
ordinal: 1250
26+
---
27+
28+
## Description
29+
30+
<!-- SECTION:DESCRIPTION:BEGIN -->
31+
## Problem
32+
33+
Two related queue/playback regressions observed on 2026-04-14:
34+
35+
### Bug 1: Double-clicking a track in library view doesn't enqueue subsequent tracks
36+
37+
When double-clicking a track (e.g. LCD Soundsystem - Losing My Edge) in the main music library view, only the clicked track plays. The rest of the library is not enqueued as context.
38+
39+
**Root cause (likely):** `library-browser.js:456` passes `this.library.filteredTracks` to `handleDoubleClickPlay()`. The library uses pagination (500 tracks/page, 9641 total). The `filteredTracks` getter (`library.js:284-298`) only returns currently loaded pages — it does NOT load all pages first.
40+
41+
Compare with the "Add All to Queue" flow (`library.js:628-631`) which explicitly calls `_loadAllPages()` before queueing. The double-click path in `queue-builder.js:27` skips this step, so only tracks from loaded pages are passed to `queue_play_context`.
42+
43+
If the user scrolled to a track in the middle of the library but earlier/later pages aren't loaded, the queue would be incomplete or the `index` parameter would be wrong relative to the partial track list.
44+
45+
### Bug 2: Playback doesn't auto-advance to next track (stuck at last ~2 seconds)
46+
47+
After manually adding a "Play Next" track (The La's - Endless), playback doesn't advance when the first track finishes. The UI shows the track stuck at approximately the last 2 seconds.
48+
49+
**Possible causes (investigation needed):**
50+
51+
1. **`is_finished()` never returns true**`engine.rs:272-279` checks `sink.empty() && self.state == Playing`. If the sink doesn't fully drain (rodio/symphonia decoder issue), or if there's a timing race where `self.state` changes before the check, the condition never triggers.
52+
53+
2. **`audio://track-ended` event emitted but not handled** — The frontend listener (`player.js:45-48`) calls `queue.playNext()` which invokes `queue_play_next_track` on the backend. If the queue has only 1 item and no next track, `playNext` may silently stop.
54+
55+
3. **Progress polling stops before track ends** — The progress emission (`audio.rs:309`) only fires when `is_playing` is true. But `get_state()` returns `Stopped` when `is_finished()` is true, so progress emission stops the same tick `is_finished` first triggers. If the last progress update showed ~2s remaining, the UI would freeze there.
56+
57+
### Reproduction
58+
59+
1. Launch mt, open main library view (All tracks, sorted by artist)
60+
2. Scroll to LCD Soundsystem - Losing My Edge, double-click
61+
3. Open Now Playing / queue panel — observe only 1 track (or a small subset) is queued
62+
4. Right-click The La's - Endless, select "Play Next"
63+
5. Wait for Losing My Edge to finish — observe playback doesn't advance
64+
65+
### Log evidence
66+
67+
```
68+
2026-04-14T15:12:56.168887Z INFO mt_lib::commands::audio: Lazily initializing audio engine on first use
69+
2026-04-14T15:12:56.341239Z INFO mt_lib::audio::engine: Track loaded path=".../01 - Losing My Edge.mp3" duration_ms=473066
70+
2026-04-14T15:13:20.826571Z WARN symphonia_bundle_mp3::layer3: mpa: invalid main_data_begin, underflow by 218 bytes
71+
```
72+
73+
No `audio://track-ended` event visible in logs (backend doesn't log it). The symphonia warning about MP3 main_data underflow may be related to Bug 2 — decoder issues could prevent the sink from fully draining.
74+
75+
## Investigation needed
76+
77+
- Add `debug!` logging around `is_finished()` transitions and `audio://track-ended` emission to confirm whether the event fires
78+
- Check if symphonia MP3 decode errors prevent `sink.empty()` from ever returning true
79+
- Verify `filteredTracks` page coverage when double-clicking from different scroll positions
80+
- Check if `queue_play_context` receives the full track list or a partial one (log the count)
81+
<!-- SECTION:DESCRIPTION:END -->
82+
83+
## Acceptance Criteria
84+
<!-- AC:BEGIN -->
85+
- [x] #1 Double-clicking a track in the main library view enqueues the full library (all pages) as context, not just loaded pages
86+
- [x] #2 Playback auto-advances to the next queued track when the current track finishes
87+
- [x] #3 Play Next tracks are played when the preceding track ends
88+
- [x] #4 No regression in shuffle, loop, or other queue navigation modes
89+
<!-- AC:END -->
90+
91+
## Implementation Notes
92+
93+
<!-- SECTION:NOTES:BEGIN -->
94+
## Investigation Results (2026-04-14)
95+
96+
### Bug 1: Root Cause Confirmed
97+
98+
`library-browser.js:452` passes `this.library.filteredTracks` to `handleDoubleClickPlay()`. The `filteredTracks` getter only returns tracks from loaded pages (pagination with 500 tracks/page). When a user double-clicks a track at global index 2500 but only pages 0 and 5 are loaded, `filteredTracks` has ~1000 entries and the index 2500 is out of bounds. The guard in `queue-builder.js:16` (`index >= allTracks.length`) triggers, falling back to single-track playback via `playTrack()`.
99+
100+
### Bug 2: Root Cause Analysis
101+
102+
`is_finished()` in `engine.rs:271-279` checks `sink.empty() && self.state == Playing`. The symphonia MP3 decode error (`invalid main_data_begin, underflow by 218 bytes`) near the end of an MP3 file can prevent the sink from fully draining. If `sink.empty()` never returns true, `is_finished()` stays false, `audio://track-ended` never fires, and the frontend's `playNext()` is never called. The UI shows progress frozen at ~2s remaining because `is_playing` remains true but the position stops advancing.
103+
104+
## Fixes Applied
105+
106+
### Bug 1 Fix: `library-browser.js`
107+
- Added `_loadAllPages()` call before `handleDoubleClickPlay()` in `handleDoubleClick()`, matching the pattern used by `addAllToQueue()`
108+
109+
### Bug 2 Fix: `audio.rs` (audio_thread)
110+
- Added stall detection: if playback position hasn't advanced for ~1 second (10 poll cycles at 100ms) and the position is within 5 seconds of the track end, treat the track as finished
111+
- Added debug logging on track-ended emission
112+
- Reset stall counters on Load/LoadAndPlay commands
113+
<!-- SECTION:NOTES:END -->
114+
115+
## Final Summary
116+
117+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
118+
## Bug 1: Double-click doesn't enqueue full library\n\n**Root cause:** `library-browser.js` passed `filteredTracks` (only loaded pages) to `handleDoubleClickPlay()`. With 500 tracks/page pagination, tracks at high global indices were out of bounds, causing fallback to single-track playback.\n\n**Fix:** Added `_loadAllPages()` call before `handleDoubleClickPlay()` in `handleDoubleClick()`, matching the pattern used by `addAllToQueue()`.\n\n## Bug 2: Playback doesn't auto-advance (stuck at last ~2s)\n\n**Root cause:** Symphonia MP3 decode errors (`invalid main_data_begin`) near end-of-track prevent rodio sink from draining. `sink.empty()` never returns true, so `is_finished()` stays false and `audio://track-ended` never fires.\n\n**Fix:** Added `StallDetector` in `commands/audio.rs` that monitors playback position during the audio thread poll loop. If position hasn't advanced for 10 consecutive polls (~1s) and is within 5s of track end, treats the track as finished.\n\n## Files changed\n- `app/frontend/js/components/library-browser.js` — `_loadAllPages()` before double-click queue\n- `crates/mt-tauri/src/commands/audio.rs` — `StallDetector` struct + integration in audio_thread + 9 unit tests\n- `app/frontend/__tests__/queue-builder.test.js` — 2 regression tests for paginated double-click
119+
<!-- SECTION:FINAL_SUMMARY:END -->

0 commit comments

Comments
 (0)