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:
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 onSkipToNext() = callJs("onNotificationNext")
|
||||
override fun onSkipToPrevious() = callJs("onNotificationPrev")
|
||||
|
||||
// ─── JS bridge helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.media3.session.CacheBitmapLoader
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.google.common.util.concurrent.MoreExecutors
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MusicService : MediaSessionService() {
|
||||
@@ -32,6 +33,8 @@ class MusicService : MediaSessionService() {
|
||||
fun onPositionUpdate(positionMs: Long, durationMs: Long)
|
||||
fun onQueueEnded()
|
||||
fun onError(message: String)
|
||||
fun onSkipToNext()
|
||||
fun onSkipToPrevious()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -106,8 +109,22 @@ class MusicService : MediaSessionService() {
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.setBitmapLoader(bitmapLoader) // use cached loader for notification artwork
|
||||
.setBitmapLoader(bitmapLoader)
|
||||
.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()
|
||||
|
||||
player.addListener(PlayerListener())
|
||||
|
||||
+58
-138
@@ -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); }
|
||||
if (single) {
|
||||
try { window.NSBridge.setQueue(single, 0, 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 (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,8 +691,8 @@ 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); }
|
||||
const s = S.queue[S.qIdx];
|
||||
if (s) try { window.NSBridge.setQueue(_nativeSingle(s), 0, _nativePlaying); }
|
||||
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;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user