import { S } from '../state.js'; import { esc, formatDur, toast } from '../utils.js'; import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js'; // views.js/ui.js circular imports — safe (only used inside function bodies) import { loadRecommendations } from '../views/views.js'; import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js'; // ─── NATIVE BRIDGE DETECTION ─────────────────────────────────────────────── // True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl). const HAS_BRIDGE = typeof window.NSBridge !== 'undefined'; // Track native playback state; driven by window.NSPlayer callbacks below. 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 let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ignored before this // ─── SCROBBLE ───────────────────────────────────────────────────────────── let _scrobbleTimer = null; let _scrobbleId = null; function _scheduleScrobble(id, durSec) { if (_scrobbleId === String(id)) return; _cancelScrobble(); _scrobbleId = String(id); const mode = S.cfg.scrobbleMode || '15s'; if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded let delay; const dur = (durSec || 0) * 1000; switch (mode) { case '30s': delay = 30_000; break; case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break; case '2/3': delay = dur > 0 ? Math.floor(dur * 2 / 3) : 60_000; break; default: delay = 15_000; break; } _scrobbleTimer = setTimeout(() => { if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {}); _scrobbleId = null; }, delay); } function _cancelScrobble() { clearTimeout(_scrobbleTimer); _scrobbleTimer = null; _scrobbleId = null; } function _scrobbleNow(s) { if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {}); } // Unified duration / position / seek helpers — use native state when available, // fall back to HTMLAudioElement when not. const _dur = () => HAS_BRIDGE ? _lastDurationMs / 1000 : (S.audio.duration || 0); const _pos = () => HAS_BRIDGE ? _nativePos / 1000 : (S.audio.currentTime || 0); const _seek = ms => HAS_BRIDGE ? _nsBridge('seekTo', ms) : (S.audio.currentTime = ms / 1000); // Safe NSBridge caller — never throws into JS. function _nsBridge(method, ...args) { try { window.NSBridge[method](...args); } catch(_) {} } // Convert a single Navidrome song to the NSBridge Track schema. function _trackToNative(s) { return { id: s.id, url: streamUrl(s.id), title: s.title || '', artist: s.artist || '', album: s.album || null, artworkUrl: s.coverArt ? coverUrl(s.coverArt, 1024) : null, durationMs: Math.floor((s.duration || 0) * 1000), }; } // Build the NSBridge Track JSON array from S.queue. const buildTrackList = () => S.queue.map(_trackToNative); // Single-track JSON for setQueue — ExoPlayer always holds exactly one item. const _nativeSingle = s => JSON.stringify([_trackToNative(s)]); // Persist the current track to Android SharedPreferences so MusicService can // pre-warm ExoPlayer on the next cold start before the WebView finishes loading. function _persistLastPlayed(s) { if (!HAS_BRIDGE || !s) return; try { window.NSBridge.saveLastPlayed(JSON.stringify(_trackToNative(s))); } catch (_) {} } // 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; 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); } } // 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. // Installed here so it exists before onPageFinished fires. window.NSPlayer = { // Called when MediaController connects (first load or WebView reload). onReady() { // Mark bridge as ready: onQueueEnded fired before this point came from a previous // session (song finished while backgrounded) and must not advance the queue. _bridgeReady = true; _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. // Match by song ID against S.queue[S.qIdx] — the JS-side current position. const currentSong = S.queue[S.qIdx]; const idMatch = !nativeId || !currentSong?.id || String(nativeId) === String(currentSong.id); if (nativeLen > 0 && nativePlaying) { if (idMatch) { // Native is playing our current song — re-sync UI without touching S.qIdx. _nativePlaying = true; _nativePos = state.positionMs || 0; if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } setPlayIcon(true); renderQueue(); } else { // Native advanced to a different song while backgrounded — find it in the queue. const found = nativeId ? S.queue.findIndex(s => String(s.id) === String(nativeId)) : -1; if (found >= 0) { S.qIdx = found; _nativePlaying = true; _nativePos = state.positionMs || 0; const s = S.queue[S.qIdx]; if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); } setPlayIcon(true); renderQueue(); } else { // Playing a song not in our queue (radio advanced past known tracks) — clear. S.queue = []; 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; _nativePos = state.positionMs || 0; if (currentSong) { updatePlayerUI(currentSong, true); onNpSongChange(currentSong.id); } setPlayIcon(false); renderQueue(); } else { // Native is idle/empty or has a mismatched paused song — push current (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 === 'one' ? 'one' : 'off'); // 'all' handled in JS _nsBridge('setShuffleMode', false); }, // Play/pause/buffer state changed. onPlaybackStateChanged(stateJson) { try { const state = JSON.parse(stateJson); const prev = _nativePlaying; _nativePlaying = state.isPlaying; if (!state.isPlaying && !state.isBuffering) _cancelScrobble(); if (state.isPlaying && S.previewCurrent) stopPreview(false); // Show pause icon while buffering (isPlaying=false but isBuffering=true) so the // button doesn't flash "play" during the brief re-buffer between tracks. const showPlaying = state.isPlaying || state.isBuffering; if (state.isPlaying !== prev) { setPlayIcon(showPlaying); document.getElementById('player-art')?.classList.toggle('spinning', showPlaying); if (navigator.mediaSession) navigator.mediaSession.playbackState = state.isPlaying ? 'playing' : 'paused'; } } catch (e) {} }, // 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 s = S.queue[S.qIdx]; if (!s) return; _scheduleScrobble(s.id, s.duration); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); } catch (e) {} }, // ~250 ms position tick while playing. onPositionUpdate(posMs, durMs) { _nativePos = posMs; _lastPositionMs = posMs; _lastDurationMs = durMs; if (S._scrubbing) return; // Single-track model: no queue drift possible, reconciliation tick removed. const dur = durMs / 1000; const cur = posMs / 1000; if (dur <= 0) return; const p = (cur / dur) * 100; document.getElementById('progress-fill').style.width = p + '%'; document.getElementById('time-cur').textContent = formatDur(cur); document.getElementById('time-total').textContent = formatDur(dur); if (S.npOpen) { const fill = document.getElementById('np-fill'); const curEl = document.getElementById('np-cur'); const totEl = document.getElementById('np-tot'); if (fill) fill.style.width = p + '%'; if (curEl) curEl.textContent = formatDur(cur); if (totEl) totEl.textContent = formatDur(dur); if (S.npLyricsMode) updateLyricsHighlight(posMs); } }, // Single track finished — JS drives what plays next. onQueueEnded() { // Ignore if onReady hasn't fired yet: the song ended while the app was backgrounded // and the callback arrived before the new page session is fully connected. // Also ignore the brief window right after onReady (stale STATE_ENDED replay). if (!_bridgeReady || Date.now() - _readyTs < 1500) return; _nativePlaying = false; setPlayIcon(false); if (S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]); 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); }, }; // ─── COVER ART URL CACHE ─────────────────────────────────────────────────── // Stable URLs let the OS cache artwork images — without this every skip regenerates a new // auth token URL, Samsung has to re-fetch the image, and the lock screen notification // visually reloads. const _coverCache = new Map(); function _cachedCoverUrl(id, size) { const key = `${id}:${size}`; if (!_coverCache.has(key)) _coverCache.set(key, coverUrl(id, size)); return _coverCache.get(key); } // ─── AUDIO SESSION — set once, never again ─────────────────────────────── let _audioSessionSet = false; function _setAudioSession() { if (HAS_BRIDGE || _audioSessionSet) return; _audioSessionSet = true; try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} } // ─── PRELOAD — shadow Audio element actually buffers the next track ────────── let _preUrl = ''; let _preId = ''; let _preAudio = null; function _nextIdx() { if(S.repeat === 'one') return S.qIdx; if(S.qIdx + 1 < S.queue.length) return S.qIdx + 1; if(S.repeat === 'all') return 0; return -1; } function _preloadNext() { if (HAS_BRIDGE) return; const ni = _nextIdx(); if(ni < 0) return; const next = S.queue[ni]; if(!next) return; // (b) EXPERIMENT — no separate preload