refactor: single-track ExoPlayer model — JS owns queue entirely

Root cause of all Android player bugs: two competing queue managers.
ExoPlayer held all tracks and auto-advanced with its own index while JS
tracked S.qIdx separately. seekToIndex() is async via runOnMain, so
skips would interleave with ExoPlayer's own advances — causing desync,
wrong-song display, and skips that silently did nothing.

Fix: ExoPlayer always holds exactly ONE MediaItem. JS is the single
source of truth for queue order, shuffle, and repeat.

JS changes (player.js):
- playQueue/playCurrent/nextSong/prevSong/toggleShuffle: replace
  setQueue(allTracks, idx) + seekToIndex with setQueue([single], 0)
- _pushQueueIfBridge: send only current track
- nativeInsertAt: no-op (radio songs land in S.queue; JS picks them up)
- fillRadioQueue: remove addToQueue native call
- onTrackChanged: simplified to just schedule scrobble + radio top-up
- onPlaybackStateChanged: remove index-reconciliation block
- onPositionUpdate: remove reconciliation tick (no queue drift possible)
- onQueueEnded: call onEnded() — JS drives what plays next
- toggleRepeat: setRepeatMode('one') only for repeat=one, else 'off'
- Add onNotificationNext / onNotificationPrev to NSPlayer
- Remove _skipping/_skipTimer/_reconTick/_setSkipping (all dead code)

Android changes:
- MusicService: add onSkipToNext/onSkipToPrevious to StateListener;
  add MediaSession.Callback to intercept notification skip buttons and
  delegate them to JS instead of letting ExoPlayer handle them
- MainActivity: implement new StateListener methods → callJs()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-04 23:57:57 +02:00
1 parent 85d8e8eae7
commit d539a43307
3 files changed
+81 -141

No files matched your search

+60 -140
View File
@@ -14,9 +14,6 @@ let _nativePos = 0; // ms
let _nativePlaying = false;
let _lastPositionMs = 0; // ms — set by onPositionUpdate, used for prev() decision
let _lastDurationMs = 0; // ms — set by onPositionUpdate, used for seek calculations
let _skipping = false; // true briefly after a skip — suppresses the transient isPlaying:false
let _skipTimer = null;
let _reconTick = 0; // counter for periodic track-reconciliation inside onPositionUpdate
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
@@ -63,19 +60,22 @@ function _trackToNative(s) {
// Build the NSBridge Track JSON array from S.queue.
const buildTrackList = () => S.queue.map(_trackToNative);
// Push the current JS queue to native whenever S.queue changes (radio top-ups, etc.).
// Single-track JSON for setQueue — ExoPlayer always holds exactly one item.
const _nativeSingle = s => JSON.stringify([_trackToNative(s)]);
// Reload native player with just the current song. The single-track model means
// ExoPlayer is never responsible for queue order — JS owns that entirely.
function _pushQueueIfBridge() {
if (!HAS_BRIDGE) return;
try {
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, !S.audio.paused);
} catch (e) { console.warn('NSBridge.setQueue failed', e); }
const s = S.queue[S.qIdx];
if (!s) return;
try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
}
// Insert a single song into the native queue at the given index (-1 = append).
export function nativeInsertAt(song, position) {
if (!HAS_BRIDGE) return;
try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {}
}
// Radio top-ups: add to S.queue only. ExoPlayer gets the new song when JS naturally
// picks it via nextSong() → setQueue([single], 0, true). No native queue manipulation.
export function nativeInsertAt(_song, _position) { /* no-op in single-track model */ }
// ─── NATIVE → JS CALLBACKS ────────────────────────────────────────────────
// window.NSPlayer is the surface that MusicService calls back into.
@@ -124,15 +124,16 @@ window.NSPlayer = {
setPlayIcon(false);
renderQueue();
} else if (!nativePlaying) {
// Native is idle/empty — safe to push JS state (e.g. after cold start).
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, false);
// Native is idle/empty — push just the current song (single-track model).
const s = S.queue[S.qIdx];
if (s) window.NSBridge.setQueue(_nativeSingle(s), 0, false);
}
} catch (e) {}
}
// Always sync shuffle/repeat settings.
// setShuffleMode is always false — we pre-shuffle the JS array ourselves and
// traverse sequentially, so ExoPlayer's internal shuffle must stay off.
_nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
_nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off'); // 'all' handled in JS
_nsBridge('setShuffleMode', false);
},
@@ -140,17 +141,6 @@ window.NSPlayer = {
onPlaybackStateChanged(stateJson) {
try {
const state = JSON.parse(stateJson);
if (_skipping) {
if (state.isPlaying) {
// isPlaying:true during skip/seek — restart grace period instead of clearing immediately,
// so a follow-up buffering false event is also suppressed.
clearTimeout(_skipTimer);
_skipTimer = setTimeout(() => { _skipping = false; }, 600);
} else {
// Suppress transient isPlaying:false during track transitions / seeks.
return;
}
}
const prev = _nativePlaying;
_nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
@@ -160,61 +150,18 @@ window.NSPlayer = {
document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying);
if (navigator.mediaSession)
navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused';
// Safety net: when playback starts, verify JS is showing the correct track.
// Catches any edge cases where onTrackChanged was missed or queue drifted.
// Skip during _skipping window: snapshot may lag and still report the OLD index.
if (state.isPlaying && !_skipping) {
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
const liveIdx = live.currentIndex;
const liveId = live.currentId || '';
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
liveIdx >= 0 && liveIdx < S.queue.length) {
const expected = S.queue[liveIdx];
// Only update if song ID confirms native and JS queues are in sync.
if (!liveId || !expected?.id || String(liveId) === String(expected.id)) {
S.qIdx = liveIdx;
if (expected) { updatePlayerUI(expected, true); onNpSongChange(expected.id); }
}
}
} catch {}
}
}
} catch (e) {}
},
// Native moved to a different track (auto-advance, seek, or queue replace).
// Native started playing a track (confirmation of our setQueue call).
// With the single-track model, JS already updated S.qIdx before calling setQueue,
// so nothing needs to change here — just trigger scrobble and radio top-up.
onTrackChanged(payloadJson) {
try {
const p = JSON.parse(payloadJson);
if (typeof p.index !== 'number') return;
// p.index comes directly from ExoPlayer (always accurate).
// Only advance to a higher live index to handle rapid forward skips where
// the callback can arrive before we processed the previous one.
// Never let a stale MediaController snapshot pull us BACK to an old index.
let idx = p.index;
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
if (typeof live.currentIndex === 'number' && live.currentIndex > idx) idx = live.currentIndex;
} catch {}
if (idx < 0 || idx >= S.queue.length) return;
// Verify using the trackId from the callback payload (ExoPlayer-direct, not the
// potentially-stale snapshot) so auto-advance is never rejected by a lagged snapshot.
const trackId = p.trackId || '';
if (trackId && S.queue[idx]?.id && String(trackId) !== String(S.queue[idx].id) && p.reason !== 'queue') return;
const indexChanged = (idx !== S.qIdx);
if (indexChanged && p.reason !== 'queue') _histPush(S.qIdx);
S.qIdx = idx;
const s = S.queue[S.qIdx];
if (!s) return;
updatePlayerUI(s, true);
syncNowPlaying();
onNpSongChange(s.id);
if (indexChanged) renderQueue();
// Schedule scrobble — fires after 12s of uninterrupted playback.
_scheduleScrobble(s.id);
// Radio mode: top up the queue when running low.
// fillRadioQueue sends addToQueue to native internally — no re-send here.
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {}
},
@@ -225,26 +172,7 @@ window.NSPlayer = {
_lastPositionMs = posMs;
_lastDurationMs = durMs;
if (S._scrubbing) return;
// Every 8 ticks (~2 s): verify JS track matches native snapshot.
// Catches the gap between auto-advance and onTrackChanged arriving in JS
// (evaluateJavascript is deferred while WebView is backgrounded/resuming).
// During _skipping window snapshot is stale — reset counter and wait.
if (_skipping) { _reconTick = 0; }
else if (HAS_BRIDGE && ++_reconTick >= 8) {
_reconTick = 0;
try {
const snap = JSON.parse(window.NSBridge.getState() || '{}');
const snapId = String(snap.currentId || '');
if (snapId && snapId !== String(S.queue[S.qIdx]?.id || '')) {
const found = S.queue.findIndex(s => String(s.id) === snapId);
if (found >= 0) {
S.qIdx = found;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); syncNowPlaying(); onNpSongChange(s.id); renderQueue(); }
}
}
} catch {}
}
// Single-track model: no queue drift possible, reconciliation tick removed.
const dur = durMs / 1000;
const cur = posMs / 1000;
if (dur <= 0) return;
@@ -263,21 +191,17 @@ window.NSPlayer = {
}
},
// Last track finished with repeat off.
// Single track finished — JS drives what plays next.
onQueueEnded() {
_nativePlaying = false;
setPlayIcon(false);
if (S.radioMode) {
const prevLen = S.queue.length;
fillRadioQueue().then(() => {
if (S.queue.length > prevLen) { _nsBridge('play'); return; }
// fillRadioQueue was locked by a concurrent fill — wait for it to finish
// then trigger play so native doesn't stay stopped after queue end.
if (_radioFilling) setTimeout(() => { if (S.queue.length > prevLen) _nsBridge('play'); }, 3000);
});
}
onEnded(); // handles repeat, radio top-up, and queue advance
},
// Notification next/prev buttons: intercept at MediaSession level so JS controls queue.
onNotificationNext() { nextSong(); },
onNotificationPrev() { prevSong(); },
onError(message) { toast('Playback error: ' + message); },
};
@@ -378,17 +302,14 @@ export function playQueue(songs, startIdx, opts = {}) {
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
renderQueue();
// Build the track list now (before rAF) while S.queue is current.
const trackListJson = JSON.stringify(buildTrackList());
// Defer the NSBridge call to AFTER the current render frame so the
// updatePlayerUI DOM changes are committed and visible before the
// @JavascriptInterface call (which blocks the JS thread while Java
// parses the JSON and creates MediaItems).
// Single-track model: ExoPlayer always holds exactly one song.
// JS owns all queue logic — no desync possible.
const single = s ? _nativeSingle(s) : null;
requestAnimationFrame(() => {
try { window.NSBridge.setQueue(trackListJson, S.qIdx, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
// fillRadioQueue AFTER setQueue so addToQueue lands in the new native queue,
// not the old one that setQueue is about to wipe.
if (single) {
try { window.NSBridge.setQueue(single, 0, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); }
}
if (S.radioMode) fillRadioQueue();
});
return;
@@ -404,14 +325,12 @@ export function playCurrent() {
const s = S.queue[S.qIdx];
if (HAS_BRIDGE) {
// Update DOM immediately for snappy feedback; onTrackChanged will confirm.
S._trackChanging = true;
_resetProgressUI();
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
return;
}
@@ -659,12 +578,6 @@ function _computeNextIdx() {
return -1;
}
function _setSkipping() {
_skipping = true;
_reconTick = 0;
clearTimeout(_skipTimer);
_skipTimer = setTimeout(() => { _skipping = false; }, 2000);
}
function _resetProgressUI() {
if (HAS_BRIDGE) _nativePos = 0;
@@ -681,7 +594,6 @@ function _resetProgressUI() {
export function prevSong() {
_clearPreload();
if (HAS_BRIDGE) {
_setSkipping();
if (_lastPositionMs > 3000) {
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
_resetProgressUI();
@@ -694,9 +606,14 @@ export function prevSong() {
if (prevIdx >= 0 && prevIdx < S.queue.length) {
S.qIdx = prevIdx;
const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); syncNowPlaying(); onNpSongChange(s.id); renderQueue(); }
_nsBridge('seekToIndex', S.qIdx, 0);
_nsBridge('play');
if (s) {
_resetProgressUI();
updatePlayerUI(s, true);
syncNowPlaying();
onNpSongChange(s.id);
renderQueue();
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
}
}
return;
}
@@ -707,18 +624,24 @@ export function prevSong() {
export function nextSong() {
_clearPreload();
if (HAS_BRIDGE) {
_setSkipping();
const ni = _computeNextIdx();
if (ni >= 0 && ni < S.queue.length) {
_histPush(S.qIdx);
S.qIdx = ni;
const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); }
// Explicit index prevents desync when JS and native queue pointers diverge.
_nsBridge('seekToIndex', ni, 0);
_nsBridge('play');
if (s) {
_resetProgressUI();
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
// Single-track: replace native player with the new song. No seekToIndex needed.
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
}
} else {
// End of queue, no repeat — stop.
_nativePlaying = false;
setPlayIcon(false);
}
// else: ni = -1 means end of queue with no repeat — let ExoPlayer stop naturally.
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
return;
}
@@ -768,9 +691,9 @@ export function toggleShuffle() {
renderQueue();
// Re-send the reordered queue to native so ExoPlayer's order matches JS.
if (HAS_BRIDGE) {
const tl = JSON.stringify(buildTrackList());
try { window.NSBridge.setQueue(tl, S.qIdx, _nativePlaying); }
catch(e) { console.warn('NSBridge.setQueue failed', e); }
const s = S.queue[S.qIdx];
if (s) try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
catch(e) { console.warn('NSBridge.setQueue failed', e); }
}
}
_applyShuffleRepeat();
@@ -780,7 +703,8 @@ export function toggleRepeat() {
S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false;
localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat);
_applyShuffleRepeat();
if (HAS_BRIDGE) _nsBridge('setRepeatMode', S.repeat === false ? 'off' : S.repeat);
// Single-track model: native repeat is only used for 'one'; 'all' is handled in JS.
if (HAS_BRIDGE) _nsBridge('setRepeatMode', S.repeat === 'one' ? 'one' : 'off');
}
// ─── RADIO ────────────────────────────────────────────────────────────────
@@ -839,11 +763,9 @@ export async function fillRadioQueue() {
}
if (gen !== _queueGen) return;
const prevLen = S.queue.length;
S.queue.push(...newSongs);
renderQueue();
// Append-only: never call setQueue here (that resets position to 0).
S.queue.slice(prevLen).forEach(s => _nsBridge('addToQueue', JSON.stringify(_trackToNative(s)), -1));
// Single-track model: songs land in S.queue; JS picks them up via nextSong(). No native push needed.
} catch {
} finally {
_radioFilling = false;
@@ -883,7 +805,6 @@ export function seekTo(e) {
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
S._scrubbing = true;
if (HAS_BRIDGE) {
_setSkipping();
_nsBridge('seekTo', Math.round(pos * _lastDurationMs));
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
setTimeout(done, 500);
@@ -913,7 +834,6 @@ export function initProgressScrub() {
// Export for lyrics / other callers that need to seek by ms.
export function seekToMs(ms) {
if (HAS_BRIDGE) {
_setSkipping();
_nsBridge('seekTo', Math.round(ms));
} else {
S.audio.currentTime = ms / 1000;