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 863f20c..d136300 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -249,6 +249,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { val json = JSONObject().apply { put("isPlaying", ctrl.isPlaying) put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING) + put("isEnded", ctrl.playbackState == Player.STATE_ENDED) put("currentIndex", ctrl.currentMediaItemIndex) put("currentId", ctrl.currentMediaItem?.mediaId ?: "") put("positionMs", ctrl.currentPosition) diff --git a/src/player.js b/src/player.js index 15b7fb6..40e061f 100644 --- a/src/player.js +++ b/src/player.js @@ -14,6 +14,7 @@ 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 _readyTs = 0; // timestamp of last onReady — used to suppress spurious onQueueEnded // ─── SCROBBLE ───────────────────────────────────────────────────────────── let _scrobbleTimer = null; @@ -84,12 +85,18 @@ window.NSPlayer = { // Called when MediaController connects (first load or WebView reload). onReady() { + // Guard window: ignore onQueueEnded for 1.5s after reconnect. + // ExoPlayer can deliver a stale STATE_ENDED callback right after the MediaController + // reconnects to a session whose last track had already finished. + _readyTs = Date.now(); + if (HAS_BRIDGE && S.queue.length > 0) { try { const raw = window.NSBridge.getState(); const state = raw ? (JSON.parse(raw) || {}) : {}; const nativeLen = state.queueLength || 0; const nativePlaying = state.isPlaying || false; + const nativeEnded = state.isEnded || false; const nativeId = state.currentId || ''; // Single-track model: native index is always 0, meaningless for queue position. @@ -123,6 +130,15 @@ window.NSPlayer = { S.qIdx = 0; } } + } else if (nativeLen > 0 && nativeEnded && idMatch) { + // Song finished while backgrounded and ExoPlayer is in STATE_ENDED. + // Reset to position 0 so the user can press play to replay — do NOT advance queue. + _nativePlaying = false; + _nativePos = 0; + if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } + setPlayIcon(false); + renderQueue(); + _nsBridge('seekTo', 0); } else if (nativeLen > 0 && !nativePlaying && idMatch) { // Paused, song matches — re-sync UI state only. _nativePlaying = false; @@ -200,6 +216,9 @@ window.NSPlayer = { // Single track finished — JS drives what plays next. onQueueEnded() { + // Suppress if fired within 1.5s of onReady — ExoPlayer can replay a stale STATE_ENDED + // callback right after the MediaController reconnects, which would skip the queue. + if (Date.now() - _readyTs < 1500) return; _nativePlaying = false; setPlayIcon(false); onEnded(); // handles repeat, radio top-up, and queue advance