diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt index 144f7bc..863f20c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -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 ──────────────────────────────────────────────────── /** diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 260abd1..50122c0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -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()) diff --git a/src/player.js b/src/player.js index fae3055..e98029a 100644 --- a/src/player.js +++ b/src/player.js @@ -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;