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
+81
-141
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())
|
||||||
|
|||||||
+60
-140
@@ -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) {
|
||||||
catch (e) { console.warn('NSBridge.setQueue failed', e); }
|
try { window.NSBridge.setQueue(single, 0, true); }
|
||||||
// fillRadioQueue AFTER setQueue so addToQueue lands in the new native queue,
|
catch (e) { console.warn('NSBridge.setQueue failed', e); }
|
||||||
// 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,9 +691,9 @@ 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); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_applyShuffleRepeat();
|
_applyShuffleRepeat();
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user