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
+79 -139

No files matched your search

@@ -215,6 +215,9 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
override fun onError(message: String) = callJs("onError", message) override fun onError(message: String) = callJs("onError", message)
override fun onSkipToNext() = callJs("onNotificationNext")
override fun onSkipToPrevious() = callJs("onNotificationPrev")
// ─── JS bridge helpers ──────────────────────────────────────────────────── // ─── JS bridge helpers ────────────────────────────────────────────────────
/** /**
@@ -19,6 +19,7 @@ import androidx.media3.session.CacheBitmapLoader
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.MoreExecutors
import com.google.common.util.concurrent.Futures
import java.util.concurrent.Executors import java.util.concurrent.Executors
class MusicService : MediaSessionService() { class MusicService : MediaSessionService() {
@@ -32,6 +33,8 @@ class MusicService : MediaSessionService() {
fun onPositionUpdate(positionMs: Long, durationMs: Long) fun onPositionUpdate(positionMs: Long, durationMs: Long)
fun onQueueEnded() fun onQueueEnded()
fun onError(message: String) fun onError(message: String)
fun onSkipToNext()
fun onSkipToPrevious()
} }
companion object { companion object {
@@ -106,8 +109,22 @@ class MusicService : MediaSessionService() {
) )
mediaSession = MediaSession.Builder(this, player) mediaSession = MediaSession.Builder(this, player)
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork .setBitmapLoader(bitmapLoader)
.setSessionActivity(notifTapPending) .setSessionActivity(notifTapPending)
.setCallback(object : MediaSession.Callback {
// Intercept notification next/prev so JS (not ExoPlayer) handles queue logic.
override fun onSkipToNextMediaItem(
session: MediaSession, controller: MediaSession.ControllerInfo
) = Futures.immediateFuture(androidx.media3.session.SessionResult(
androidx.media3.session.SessionResult.RESULT_SUCCESS
)).also { stateListener?.onSkipToNext() }
override fun onSkipToPreviousMediaItem(
session: MediaSession, controller: MediaSession.ControllerInfo
) = Futures.immediateFuture(androidx.media3.session.SessionResult(
androidx.media3.session.SessionResult.RESULT_SUCCESS
)).also { stateListener?.onSkipToPrevious() }
})
.build() .build()
player.addListener(PlayerListener()) player.addListener(PlayerListener())
+58 -138
View File
@@ -14,9 +14,6 @@ let _nativePos = 0; // ms
let _nativePlaying = false; let _nativePlaying = false;
let _lastPositionMs = 0; // ms — set by onPositionUpdate, used for prev() decision let _lastPositionMs = 0; // ms — set by onPositionUpdate, used for prev() decision
let _lastDurationMs = 0; // ms — set by onPositionUpdate, used for seek calculations 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 ───────────────────────────────────────────────────────────── // ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null; let _scrobbleTimer = null;
@@ -63,19 +60,22 @@ function _trackToNative(s) {
// Build the NSBridge Track JSON array from S.queue. // Build the NSBridge Track JSON array from S.queue.
const buildTrackList = () => S.queue.map(_trackToNative); 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() { function _pushQueueIfBridge() {
if (!HAS_BRIDGE) return; if (!HAS_BRIDGE) return;
try { const s = S.queue[S.qIdx];
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, !S.audio.paused); if (!s) return;
} catch (e) { console.warn('NSBridge.setQueue failed', e); } 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). // Radio top-ups: add to S.queue only. ExoPlayer gets the new song when JS naturally
export function nativeInsertAt(song, position) { // picks it via nextSong() → setQueue([single], 0, true). No native queue manipulation.
if (!HAS_BRIDGE) return; export function nativeInsertAt(_song, _position) { /* no-op in single-track model */ }
try { window.NSBridge.addToQueue(JSON.stringify(_trackToNative(song)), position); } catch(_) {}
}
// ─── NATIVE → JS CALLBACKS ──────────────────────────────────────────────── // ─── NATIVE → JS CALLBACKS ────────────────────────────────────────────────
// window.NSPlayer is the surface that MusicService calls back into. // window.NSPlayer is the surface that MusicService calls back into.
@@ -124,15 +124,16 @@ window.NSPlayer = {
setPlayIcon(false); setPlayIcon(false);
renderQueue(); renderQueue();
} else if (!nativePlaying) { } else if (!nativePlaying) {
// Native is idle/empty — safe to push JS state (e.g. after cold start). // Native is idle/empty — push just the current song (single-track model).
window.NSBridge.setQueue(JSON.stringify(buildTrackList()), S.qIdx, false); const s = S.queue[S.qIdx];
if (s) window.NSBridge.setQueue(_nativeSingle(s), 0, false);
} }
} catch (e) {} } catch (e) {}
} }
// Always sync shuffle/repeat settings. // Always sync shuffle/repeat settings.
// setShuffleMode is always false — we pre-shuffle the JS array ourselves and // setShuffleMode is always false — we pre-shuffle the JS array ourselves and
// traverse sequentially, so ExoPlayer's internal shuffle must stay off. // 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); _nsBridge('setShuffleMode', false);
}, },
@@ -140,17 +141,6 @@ window.NSPlayer = {
onPlaybackStateChanged(stateJson) { onPlaybackStateChanged(stateJson) {
try { try {
const state = JSON.parse(stateJson); 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; const prev = _nativePlaying;
_nativePlaying = state.isPlaying; _nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble(); if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
@@ -160,61 +150,18 @@ window.NSPlayer = {
document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying); document.getElementById('player-art')?.classList.toggle('spinning', state.isPlaying);
if (navigator.mediaSession) if (navigator.mediaSession)
navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused'; 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) {} } 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) { onTrackChanged(payloadJson) {
try { 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]; const s = S.queue[S.qIdx];
if (!s) return; if (!s) return;
updatePlayerUI(s, true);
syncNowPlaying();
onNpSongChange(s.id);
if (indexChanged) renderQueue();
// Schedule scrobble — fires after 12s of uninterrupted playback.
_scheduleScrobble(s.id); _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(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {} } catch (e) {}
}, },
@@ -225,26 +172,7 @@ window.NSPlayer = {
_lastPositionMs = posMs; _lastPositionMs = posMs;
_lastDurationMs = durMs; _lastDurationMs = durMs;
if (S._scrubbing) return; if (S._scrubbing) return;
// Every 8 ticks (~2 s): verify JS track matches native snapshot. // Single-track model: no queue drift possible, reconciliation tick removed.
// 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 {}
}
const dur = durMs / 1000; const dur = durMs / 1000;
const cur = posMs / 1000; const cur = posMs / 1000;
if (dur <= 0) return; 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() { onQueueEnded() {
_nativePlaying = false; _nativePlaying = false;
setPlayIcon(false); setPlayIcon(false);
if (S.radioMode) { onEnded(); // handles repeat, radio top-up, and queue advance
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);
});
}
}, },
// Notification next/prev buttons: intercept at MediaSession level so JS controls queue.
onNotificationNext() { nextSong(); },
onNotificationPrev() { prevSong(); },
onError(message) { toast('Playback error: ' + message); }, onError(message) { toast('Playback error: ' + message); },
}; };
@@ -378,17 +302,14 @@ export function playQueue(songs, startIdx, opts = {}) {
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); } if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
renderQueue(); renderQueue();
// Build the track list now (before rAF) while S.queue is current. // Single-track model: ExoPlayer always holds exactly one song.
const trackListJson = JSON.stringify(buildTrackList()); // JS owns all queue logic — no desync possible.
// Defer the NSBridge call to AFTER the current render frame so the const single = s ? _nativeSingle(s) : null;
// updatePlayerUI DOM changes are committed and visible before the
// @JavascriptInterface call (which blocks the JS thread while Java
// parses the JSON and creates MediaItems).
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { window.NSBridge.setQueue(trackListJson, S.qIdx, true); } if (single) {
try { window.NSBridge.setQueue(single, 0, true); }
catch (e) { console.warn('NSBridge.setQueue failed', e); } 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 (S.radioMode) fillRadioQueue(); if (S.radioMode) fillRadioQueue();
}); });
return; return;
@@ -404,14 +325,12 @@ export function playCurrent() {
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
// Update DOM immediately for snappy feedback; onTrackChanged will confirm.
S._trackChanging = true; S._trackChanging = true;
_resetProgressUI(); _resetProgressUI();
updatePlayerUI(s, true); updatePlayerUI(s, true);
onNpSongChange(s.id); onNpSongChange(s.id);
renderQueue(); renderQueue();
_nsBridge('seekToIndex', S.qIdx, 0); try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
_nsBridge('play');
return; return;
} }
@@ -659,12 +578,6 @@ function _computeNextIdx() {
return -1; return -1;
} }
function _setSkipping() {
_skipping = true;
_reconTick = 0;
clearTimeout(_skipTimer);
_skipTimer = setTimeout(() => { _skipping = false; }, 2000);
}
function _resetProgressUI() { function _resetProgressUI() {
if (HAS_BRIDGE) _nativePos = 0; if (HAS_BRIDGE) _nativePos = 0;
@@ -681,7 +594,6 @@ function _resetProgressUI() {
export function prevSong() { export function prevSong() {
_clearPreload(); _clearPreload();
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
_setSkipping();
if (_lastPositionMs > 3000) { if (_lastPositionMs > 3000) {
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic. // Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
_resetProgressUI(); _resetProgressUI();
@@ -694,9 +606,14 @@ export function prevSong() {
if (prevIdx >= 0 && prevIdx < S.queue.length) { if (prevIdx >= 0 && prevIdx < S.queue.length) {
S.qIdx = prevIdx; S.qIdx = prevIdx;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); syncNowPlaying(); onNpSongChange(s.id); renderQueue(); } if (s) {
_nsBridge('seekToIndex', S.qIdx, 0); _resetProgressUI();
_nsBridge('play'); updatePlayerUI(s, true);
syncNowPlaying();
onNpSongChange(s.id);
renderQueue();
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
}
} }
return; return;
} }
@@ -707,18 +624,24 @@ export function prevSong() {
export function nextSong() { export function nextSong() {
_clearPreload(); _clearPreload();
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
_setSkipping();
const ni = _computeNextIdx(); const ni = _computeNextIdx();
if (ni >= 0 && ni < S.queue.length) { if (ni >= 0 && ni < S.queue.length) {
_histPush(S.qIdx); _histPush(S.qIdx);
S.qIdx = ni; S.qIdx = ni;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (s) { _resetProgressUI(); updatePlayerUI(s, true); onNpSongChange(s.id); renderQueue(); } if (s) {
// Explicit index prevents desync when JS and native queue pointers diverge. _resetProgressUI();
_nsBridge('seekToIndex', ni, 0); updatePlayerUI(s, true);
_nsBridge('play'); 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(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
return; return;
} }
@@ -768,8 +691,8 @@ export function toggleShuffle() {
renderQueue(); renderQueue();
// Re-send the reordered queue to native so ExoPlayer's order matches JS. // Re-send the reordered queue to native so ExoPlayer's order matches JS.
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
const tl = JSON.stringify(buildTrackList()); const s = S.queue[S.qIdx];
try { window.NSBridge.setQueue(tl, S.qIdx, _nativePlaying); } if (s) try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
catch(e) { console.warn('NSBridge.setQueue failed', e); } catch(e) { console.warn('NSBridge.setQueue failed', e); }
} }
} }
@@ -780,7 +703,8 @@ export function toggleRepeat() {
S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false; S.repeat = S.repeat === false ? 'all' : S.repeat === 'all' ? 'one' : false;
localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat); localStorage.setItem('ns_repeat', S.repeat === false ? '0' : S.repeat);
_applyShuffleRepeat(); _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 ──────────────────────────────────────────────────────────────── // ─── RADIO ────────────────────────────────────────────────────────────────
@@ -839,11 +763,9 @@ export async function fillRadioQueue() {
} }
if (gen !== _queueGen) return; if (gen !== _queueGen) return;
const prevLen = S.queue.length;
S.queue.push(...newSongs); S.queue.push(...newSongs);
renderQueue(); renderQueue();
// Append-only: never call setQueue here (that resets position to 0). // Single-track model: songs land in S.queue; JS picks them up via nextSong(). No native push needed.
S.queue.slice(prevLen).forEach(s => _nsBridge('addToQueue', JSON.stringify(_trackToNative(s)), -1));
} catch { } catch {
} finally { } finally {
_radioFilling = false; _radioFilling = false;
@@ -883,7 +805,6 @@ export function seekTo(e) {
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; } if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
S._scrubbing = true; S._scrubbing = true;
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
_setSkipping();
_nsBridge('seekTo', Math.round(pos * _lastDurationMs)); _nsBridge('seekTo', Math.round(pos * _lastDurationMs));
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); }; const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
setTimeout(done, 500); setTimeout(done, 500);
@@ -913,7 +834,6 @@ export function initProgressScrub() {
// Export for lyrics / other callers that need to seek by ms. // Export for lyrics / other callers that need to seek by ms.
export function seekToMs(ms) { export function seekToMs(ms) {
if (HAS_BRIDGE) { if (HAS_BRIDGE) {
_setSkipping();
_nsBridge('seekTo', Math.round(ms)); _nsBridge('seekTo', Math.round(ms));
} else { } else {
S.audio.currentTime = ms / 1000; S.audio.currentTime = ms / 1000;