1435 lines
60 KiB
JavaScript
1435 lines
60 KiB
JavaScript
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, renderNpQueue } from './nowplaying.js';
|
||
import { logPlay } from './scrobbleLog.js';
|
||
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
|
||
import { queueNachEntfernen, queueNachVerschieben } from './queueMath.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(s) {
|
||
const id = s?.id;
|
||
if (!id || _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 = (s?.duration || 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(() => {});
|
||
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
|
||
}
|
||
_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(() => {});
|
||
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
|
||
}
|
||
}
|
||
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
|
||
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
|
||
// from the delayed "played" scrobble above, which counts the play for stats / Last.fm.
|
||
function _scrobbleNowPlaying(s) {
|
||
if (s) api('scrobble', `&id=${s.id}&submission=false`).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);
|
||
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 — the standby audio element buffers the next track ──────────────
|
||
// Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to
|
||
// it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the
|
||
// new artwork is ready too. Falls back to a source-swap when the next track isn't preloaded.
|
||
let _preUrl = '';
|
||
let _preId = '';
|
||
|
||
/** The audio element that is NOT currently active (or null if there's only one). */
|
||
function _standbyEl() {
|
||
const els = S._audioEls;
|
||
if (!els || els.length < 2) return null;
|
||
return els.find(e => e !== S.audio) || 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 || _preId === next.id) return;
|
||
const standby = _standbyEl();
|
||
if(!standby) return;
|
||
_preId = next.id;
|
||
_preUrl = streamUrl(next.id);
|
||
standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly
|
||
try { standby.load(); } catch {}
|
||
if(next.coverArt) {
|
||
[256, 512, 1024].forEach(size => {
|
||
const img = new Image();
|
||
img.src = _cachedCoverUrl(next.coverArt, size);
|
||
});
|
||
}
|
||
}
|
||
|
||
function _clearPreload() {
|
||
_preId = ''; _preUrl = '';
|
||
const standby = _standbyEl();
|
||
if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} }
|
||
}
|
||
|
||
// ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
|
||
// Records the qIdx before each forward advance so prevSong() can retrace the
|
||
// actual play order instead of blindly decrementing (which is wrong in shuffle).
|
||
const _playHist = [];
|
||
function _histPush(idx) {
|
||
if (_playHist[_playHist.length - 1] === idx) return;
|
||
_playHist.push(idx);
|
||
if (_playHist.length > 50) _playHist.shift();
|
||
}
|
||
function _histClear() { _playHist.length = 0; }
|
||
|
||
// ─── PLAYBACK ─────────────────────────────────────────────────────────────
|
||
let _playGen = 0;
|
||
// Incremented on every playQueue() call so in-flight fillRadioQueue callbacks
|
||
// from the previous queue can detect they're stale and bail out.
|
||
let _queueGen = 0;
|
||
|
||
export function playQueue(songs, startIdx, opts = {}) {
|
||
_queueGen++;
|
||
_radioFilling = false; // cancel any in-flight radio fill for the old queue
|
||
S._origQueue = null;
|
||
_histClear();
|
||
// Shuffle: fully random order, no pinning — startIdx is ignored.
|
||
// Both web and Android traverse this fixed shuffled order sequentially.
|
||
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
|
||
if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
|
||
S._origQueue = songs.map(s => ({...s}));
|
||
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s}));
|
||
S.qIdx = 0;
|
||
} else {
|
||
S.queue = songs.map(s => ({...s}));
|
||
S.qIdx = startIdx;
|
||
}
|
||
_clearPreload();
|
||
|
||
if (HAS_BRIDGE) {
|
||
const s = S.queue[S.qIdx];
|
||
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
|
||
renderQueue();
|
||
// Single-track model: ExoPlayer always holds exactly one song.
|
||
// JS owns all queue logic — no desync possible.
|
||
const single = s ? _nativeSingle(s) : null;
|
||
requestAnimationFrame(() => {
|
||
if (single) {
|
||
try { window.NSBridge.setQueue(single, 0, true); }
|
||
catch (e) { console.warn('NSBridge.setQueue failed', e); }
|
||
}
|
||
if (S.radioMode) fillRadioQueue();
|
||
});
|
||
return;
|
||
}
|
||
|
||
playCurrent();
|
||
renderQueue();
|
||
if(S.radioMode) fillRadioQueue();
|
||
}
|
||
|
||
export function playCurrent() {
|
||
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
|
||
S._userPaused = false; // starting a track is intent to play — clear any stale pause intent
|
||
const s = S.queue[S.qIdx];
|
||
|
||
if (HAS_BRIDGE) {
|
||
S._trackChanging = true;
|
||
_nativePlaying = true;
|
||
_resetProgressUI();
|
||
updatePlayerUI(s, true);
|
||
setPlayIcon(true);
|
||
onNpSongChange(s.id);
|
||
renderQueue();
|
||
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
|
||
return;
|
||
}
|
||
|
||
const gen = ++_playGen;
|
||
S._trackChanging = true;
|
||
_resetProgressUI();
|
||
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
|
||
updatePlayerUI(s, true);
|
||
updateMediaSession(s);
|
||
onNpSongChange(s.id);
|
||
renderQueue();
|
||
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
|
||
_scheduleScrobble(s);
|
||
|
||
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
|
||
|
||
const _applySession = () => {
|
||
if (_playGen !== gen) return;
|
||
updateMediaSession(s);
|
||
setTimeout(_preloadNext, 2000);
|
||
};
|
||
|
||
// ── Instant skip: if the standby element already buffered THIS track, hand playback over
|
||
// to it (no source reload → no ~1s wait). The new title/cover were already set above. ──
|
||
const standby = _standbyEl();
|
||
if (standby && _preId === s.id && standby.readyState >= 2) {
|
||
const old = S.audio;
|
||
standby.currentTime = 0;
|
||
standby.volume = old.volume; standby.muted = old.muted; standby.playbackRate = old.playbackRate || 1;
|
||
S.audio = standby; // the active element is now the preloaded one
|
||
_preId = ''; _preUrl = '';
|
||
try { old.pause(); } catch {} // 'pause' is ignored for the now-inactive element
|
||
_setAudioSession();
|
||
S.audio.play()
|
||
.then(() => { S._trackChanging = false; _applySession(); })
|
||
.catch(() => {
|
||
// Handoff playback failed — recover with a fresh source load on this element.
|
||
if (_playGen !== gen) return;
|
||
S.audio.src = streamUrl(s.id);
|
||
try { S.audio.load(); } catch {}
|
||
S.audio.play().then(() => { S._trackChanging = false; _applySession(); }).catch(_resetFlag);
|
||
});
|
||
return;
|
||
}
|
||
|
||
// ── Fallback: classic source swap on the current element (unchanged) ──
|
||
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
|
||
_clearPreload();
|
||
S.audio.src = src;
|
||
|
||
let _guardTimer = null;
|
||
let _canplayFn = null;
|
||
|
||
function _armCanplay() {
|
||
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
||
clearTimeout(_guardTimer);
|
||
_guardTimer = setTimeout(() => {
|
||
if (_playGen !== gen) return;
|
||
S._trackChanging = false;
|
||
S.audio.play().then(_applySession).catch(() => {});
|
||
}, 10000);
|
||
_canplayFn = () => {
|
||
clearTimeout(_guardTimer);
|
||
_canplayFn = null;
|
||
if (_playGen !== gen) return;
|
||
if (!S.audio.paused) { _applySession(); return; }
|
||
_setAudioSession();
|
||
S.audio.play().then(_applySession).catch(_resetFlag);
|
||
};
|
||
S.audio.addEventListener('canplay', _canplayFn, { once: true });
|
||
}
|
||
|
||
_armCanplay();
|
||
_setAudioSession();
|
||
S.audio.play()
|
||
.then(() => {
|
||
clearTimeout(_guardTimer);
|
||
if (_canplayFn) S.audio.removeEventListener('canplay', _canplayFn);
|
||
_applySession();
|
||
})
|
||
.catch(err => {
|
||
if (_playGen !== gen) return;
|
||
S.audio.src = src;
|
||
S.audio.load();
|
||
_armCanplay();
|
||
});
|
||
}
|
||
|
||
function _updatePlayingHighlights() {
|
||
const id = String(S.queue[S.qIdx]?.id ?? '');
|
||
document.querySelectorAll('.song-item[data-song-id]').forEach(el => {
|
||
el.classList.toggle('playing', el.dataset.songId === id);
|
||
});
|
||
}
|
||
|
||
// Returns [{name, id}] using Navidrome's resolved artists when available,
|
||
// otherwise splits the artist string on ';' only (safe for names like Au/Ra).
|
||
export function parseArtists(s) {
|
||
if (Array.isArray(s.artists) && s.artists.length > 1) return s.artists;
|
||
return (s.artist || '').split(/\s*;\s*/).filter(Boolean)
|
||
.map((name, i) => ({ name, id: i === 0 ? (s.artistId || '') : '' }));
|
||
}
|
||
|
||
export function updatePlayerUI(s, _skipMediaSession = false) {
|
||
document.getElementById('player-title').textContent = dispTitle(s);
|
||
const artistParts = dispArtistParts(s, parseArtists);
|
||
document.getElementById('player-artist-name').innerHTML = artistParts.length
|
||
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
|
||
.join('<span style="color:var(--text3)"> • </span>')
|
||
: '—';
|
||
document.getElementById('player-album-name').textContent = dispAlbum(s);
|
||
const art = document.getElementById('player-art');
|
||
const cv = coverUrl(s.coverArt, 256);
|
||
const existingImg = art.querySelector('img');
|
||
if (cv && existingImg) { if (existingImg.src !== cv) existingImg.src = cv; }
|
||
else art.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||
updateStarBtn();
|
||
document.title = `${s.title} — NovaSonic`;
|
||
if (!_skipMediaSession) updateMediaSession(s);
|
||
if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations();
|
||
_persistLastPlayed(s);
|
||
try {
|
||
localStorage.setItem('ns_last_played', JSON.stringify({
|
||
song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId,
|
||
artistId:s.artistId, coverArt:s.coverArt, duration:s.duration, _starred:s._starred||false,
|
||
artists:s.artists||[] },
|
||
queue: S.queue.map(t => ({ id:t.id, title:t.title, artist:t.artist, album:t.album,
|
||
albumId:t.albumId, artistId:t.artistId, coverArt:t.coverArt,
|
||
duration:t.duration, _starred:t._starred||false,
|
||
artists:t.artists||[] })),
|
||
qIdx: S.qIdx,
|
||
}));
|
||
} catch {}
|
||
_syncNpDirect(s);
|
||
_updatePlayingHighlights();
|
||
// Keep the NP cover element pointing at the CURRENT song even while the overlay is closed,
|
||
// so opening it after rapid skips never shows a stale cover (it also preloads in the background).
|
||
const _npCv = coverUrl(s.coverArt, 1024);
|
||
const _npCover = document.getElementById('np-cover');
|
||
const _npImg = _npCover?.querySelector('img');
|
||
if (_npCv) {
|
||
if (_npImg) { if (_npImg.src !== _npCv) _npImg.src = _npCv; }
|
||
else if (_npCover) _npCover.innerHTML = `<img src="${_npCv}" style="width:100%;height:100%;object-fit:cover">`;
|
||
}
|
||
// Pre-warm the next song's 1024 cover so auto-advance NP swap is instant from cache
|
||
const _nextForPreload = S.qIdx + 1 < S.queue.length ? S.queue[S.qIdx + 1] : null;
|
||
if (_nextForPreload?.coverArt) {
|
||
const _nextCv = coverUrl(_nextForPreload.coverArt, 1024);
|
||
if (_nextCv) { const _preN = new Image(); _preN.src = _nextCv; }
|
||
}
|
||
}
|
||
|
||
// ── Solid cover-color background ────────────────────────────────────────────
|
||
// Fetch the cover as a blob (bypasses canvas CORS taint), draw on a tiny
|
||
// canvas, find the most vivid pixel, darken it to a moody solid background.
|
||
let _npColorActiveUrl = '';
|
||
async function _extractAndApplyColor(cv) {
|
||
if (!cv || !S.cfg?.coverColorBg) return;
|
||
if (cv === _npColorActiveUrl) return;
|
||
_npColorActiveUrl = cv;
|
||
try {
|
||
const res = await fetch(cv, { signal: AbortSignal.timeout(6000) });
|
||
const blob = await res.blob();
|
||
const burl = URL.createObjectURL(blob);
|
||
const img = await new Promise((ok, err) => {
|
||
const i = new Image(); i.onload = () => ok(i); i.onerror = err; i.src = burl;
|
||
});
|
||
URL.revokeObjectURL(burl);
|
||
if (cv !== _npColorActiveUrl) return; // superseded by next song
|
||
const c = document.createElement('canvas'); c.width = c.height = 24;
|
||
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, 24, 24);
|
||
const d = ctx.getImageData(0, 0, 24, 24).data;
|
||
let br = 20, bg2 = 20, bb = 40, best = 0;
|
||
for (let i = 0; i < d.length; i += 4) {
|
||
const r = d[i], g = d[i+1], b = d[i+2];
|
||
const mx = Math.max(r,g,b), mn = Math.min(r,g,b);
|
||
const sat = mx > 0 ? (mx-mn)/mx : 0;
|
||
const lum = (r+g+b)/3;
|
||
const score = sat * 2 + (lum > 30 && lum < 220 ? 1 : 0);
|
||
if (score > best) { best = score; br = r; bg2 = g; bb = b; }
|
||
}
|
||
// Darken significantly for a moody solid bg
|
||
const el = document.getElementById('np-overlay');
|
||
if (el && S.cfg?.coverColorBg)
|
||
el.style.setProperty('--np-solid', `${Math.round(br*.28)},${Math.round(bg2*.28)},${Math.round(bb*.28)}`);
|
||
} catch {}
|
||
}
|
||
|
||
export function updateNpColorMode() {
|
||
const el = document.getElementById('np-overlay');
|
||
if (!el) return;
|
||
const on = !!S.cfg?.coverColorBg;
|
||
el.classList.toggle('dynamic-color', on);
|
||
if (!on) { el.style.removeProperty('--np-solid'); _npColorActiveUrl = ''; }
|
||
else if (_npBgLastUrl) _extractAndApplyColor(_npBgLastUrl);
|
||
}
|
||
|
||
let _npBgCurr = 'a';
|
||
let _npBgLastUrl = '';
|
||
function _crossfadeNpBg(cv) {
|
||
if ((cv || '') === _npBgLastUrl) return;
|
||
_npBgLastUrl = cv || '';
|
||
if (S.cfg?.coverColorBg) _extractAndApplyColor(cv || '');
|
||
const doFade = () => {
|
||
const currEl = document.getElementById(`np-bg-${_npBgCurr}`);
|
||
const nextId = _npBgCurr === 'a' ? 'b' : 'a';
|
||
const nextEl = document.getElementById(`np-bg-${nextId}`);
|
||
if (!currEl || !nextEl) return;
|
||
nextEl.style.backgroundImage = cv ? `url('${cv}')` : 'none';
|
||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||
nextEl.style.opacity = '1';
|
||
currEl.style.opacity = '0';
|
||
_npBgCurr = nextId;
|
||
}));
|
||
};
|
||
if (!cv) { doFade(); return; }
|
||
const img = new Image();
|
||
img.onload = doFade;
|
||
img.onerror = doFade;
|
||
img.src = cv;
|
||
}
|
||
|
||
export function _syncNpDirect(s) {
|
||
if(!S.npOpen || !s) return;
|
||
const cv = coverUrl(s.coverArt, 1024);
|
||
const cover = document.getElementById('np-cover');
|
||
if (cover) {
|
||
const existingImg = cover.querySelector('img');
|
||
if (cv && existingImg) { if (existingImg.src !== cv) existingImg.src = cv; }
|
||
else cover.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||
}
|
||
_crossfadeNpBg(cv);
|
||
const t = document.getElementById('np-title'); if(t) t.textContent = dispTitle(s) || '—';
|
||
const an = document.getElementById('np-artist-name');
|
||
if(an) {
|
||
const parts = dispArtistParts(s, parseArtists);
|
||
an.innerHTML = parts.length
|
||
? parts.map(a => `<span style="cursor:pointer" onclick="closeNowPlaying();s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
|
||
.join('<span style="opacity:.5"> • </span>')
|
||
: '—';
|
||
}
|
||
const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = dispAlbum(s);
|
||
syncNpStar();
|
||
}
|
||
|
||
// Bump _playGen so any stale canplay/guard callbacks from the last playCurrent()
|
||
// bail out and don't call S.audio.play() after ABS has taken over audio.
|
||
export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
|
||
|
||
// ─── TRANSPORT ────────────────────────────────────────────────────────────
|
||
|
||
export function togglePlay() {
|
||
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
|
||
if(S.audio.src) {
|
||
if (S.audio.paused) { S._userPaused = false; S.audio.play(); }
|
||
else { S._userPaused = true; S.audio.pause(); _cancelScrobble(); }
|
||
}
|
||
}
|
||
|
||
export const isPlaying = () => HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
|
||
|
||
export function setPlayIcon(playing) {
|
||
document.getElementById('play-icon').innerHTML = playing
|
||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||
if(S.npOpen) {
|
||
document.getElementById('np-play-icon').innerHTML = playing
|
||
? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
|
||
: '<polygon points="5 3 19 12 5 21 5 3"/>';
|
||
const cover = document.getElementById('np-cover');
|
||
if(cover) cover.classList.toggle('playing', playing);
|
||
}
|
||
}
|
||
|
||
function _computeNextIdx() {
|
||
if (S.repeat === 'one') return S.qIdx;
|
||
// No special shuffle case — queue is already in shuffled order when S.shuffle is on.
|
||
if (S.qIdx + 1 < S.queue.length) return S.qIdx + 1;
|
||
if (S.repeat === 'all') return 0;
|
||
return -1;
|
||
}
|
||
|
||
|
||
function _resetProgressUI() {
|
||
if (HAS_BRIDGE) _nativePos = 0;
|
||
document.getElementById('progress-fill').style.width = '0%';
|
||
document.getElementById('time-cur').textContent = formatDur(0);
|
||
if (S.npOpen) {
|
||
const fill = document.getElementById('np-fill');
|
||
const cur = document.getElementById('np-cur');
|
||
if (fill) fill.style.width = '0%';
|
||
if (cur) cur.textContent = formatDur(0);
|
||
}
|
||
}
|
||
|
||
export function prevSong() {
|
||
_clearPreload();
|
||
if (HAS_BRIDGE) {
|
||
if (_lastPositionMs > 3000) {
|
||
// Restart current song explicitly — avoids relying on ExoPlayer's implicit prev-vs-restart logic.
|
||
_resetProgressUI();
|
||
_nsBridge('seekTo', 0);
|
||
return;
|
||
}
|
||
// Pop history to retrace actual play order (correct in shuffle where next jumps randomly).
|
||
// Fall back to S.qIdx-1 when history is empty (e.g. at the very start of a queue).
|
||
const prevIdx = _playHist.length > 0 ? _playHist.pop() : (S.qIdx > 0 ? S.qIdx - 1 : -1);
|
||
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();
|
||
try { window.NSBridge.setQueue(_nativeSingle(s), 0, true); } catch(_) {}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if(S.audio.currentTime > 3) { _resetProgressUI(); S.audio.currentTime = 0; return; }
|
||
if(S.qIdx > 0) { S.qIdx--; playCurrent(); }
|
||
}
|
||
|
||
export function nextSong() {
|
||
_clearPreload();
|
||
if (HAS_BRIDGE) {
|
||
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();
|
||
// 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);
|
||
}
|
||
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
|
||
return;
|
||
}
|
||
if(S.qIdx < S.queue.length - 1) S.qIdx++;
|
||
else if(S.repeat) S.qIdx = 0;
|
||
else if(S.radioMode) { fillRadioQueue().then(() => { if(S.qIdx < S.queue.length-1) { S.qIdx++; playCurrent(); } }); return; }
|
||
else return;
|
||
if(S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
|
||
playCurrent();
|
||
}
|
||
|
||
export function onEnded() {
|
||
if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; }
|
||
if (!HAS_BRIDGE && S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
|
||
_cancelScrobble();
|
||
nextSong();
|
||
}
|
||
|
||
export function _applyShuffleRepeat() {
|
||
document.getElementById('btn-shuffle')?.classList.toggle('active', S.shuffle);
|
||
document.getElementById('np-shuffle-btn')?.classList.toggle('active', S.shuffle);
|
||
document.getElementById('badge-shuffle')?.classList.toggle('on', S.shuffle);
|
||
document.getElementById('btn-repeat')?.classList.toggle('active', S.repeat !== false);
|
||
document.getElementById('btn-repeat')?.classList.toggle('repeat-one', S.repeat === 'one');
|
||
const nb = document.getElementById('np-repeat-btn');
|
||
if(nb) { nb.classList.toggle('active', S.repeat !== false); nb.classList.toggle('repeat-one', S.repeat === 'one'); }
|
||
const badge = document.getElementById('badge-repeat');
|
||
if(badge) { badge.classList.toggle('on', S.repeat !== false); badge.textContent = S.repeat === 'one' ? '🔂 Einmal' : '🔁 Repeat'; }
|
||
}
|
||
|
||
export function toggleShuffle() {
|
||
S.shuffle = !S.shuffle;
|
||
localStorage.setItem('ns_shuffle', S.shuffle ? '1' : '0');
|
||
if (S.queue.length > 1) {
|
||
if (S.shuffle) {
|
||
const cur = S.queue[S.qIdx];
|
||
S._origQueue = [...S.queue];
|
||
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5);
|
||
S.queue = [cur, ...rest];
|
||
S.qIdx = 0;
|
||
} else if (S._origQueue) {
|
||
const cur = S.queue[S.qIdx];
|
||
S.queue = S._origQueue;
|
||
S._origQueue = null;
|
||
const idx = S.queue.findIndex(s => s.id === cur?.id);
|
||
S.qIdx = idx >= 0 ? idx : 0;
|
||
}
|
||
renderQueue();
|
||
// Re-send the reordered queue to native so ExoPlayer's order matches JS.
|
||
if (HAS_BRIDGE) {
|
||
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();
|
||
}
|
||
|
||
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();
|
||
// 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 ────────────────────────────────────────────────────────────────
|
||
|
||
export function _applyRadioBtn() {
|
||
const btn = document.getElementById('btn-radio-mode');
|
||
if(btn) { btn.classList.toggle('active', S.radioMode); btn.style.color = S.radioMode ? 'var(--blue)' : ''; }
|
||
const nb = document.getElementById('np-radio-btn');
|
||
if(nb) { nb.classList.toggle('active', S.radioMode); nb.style.color = S.radioMode ? 'var(--blue)' : ''; }
|
||
}
|
||
|
||
export async function toggleRadioMode() {
|
||
S.radioMode = !S.radioMode;
|
||
localStorage.setItem('ns_radio', S.radioMode ? '1' : '0');
|
||
_applyRadioBtn();
|
||
if(S.radioMode) await fillRadioQueue();
|
||
}
|
||
|
||
let _radioFilling = false;
|
||
|
||
export async function fillRadioQueue() {
|
||
if (!S.radioMode || _radioFilling) return;
|
||
_radioFilling = true;
|
||
const gen = _queueGen;
|
||
try {
|
||
const seed = S.queue[S.qIdx];
|
||
let newSongs = [];
|
||
|
||
// 1. Try similar songs seeded from the current track
|
||
if (seed?.id) {
|
||
const r = await api('getSimilarSongs2', `&id=${seed.id}&count=20`);
|
||
newSongs = r.similarSongs2?.song || [];
|
||
}
|
||
if (gen !== _queueGen) return;
|
||
|
||
// 2. Fall back to random if similar songs unavailable or empty
|
||
if (!newSongs.length) {
|
||
const r = await api('getRandomSongs', '&size=20');
|
||
newSongs = r.randomSongs?.song || [];
|
||
}
|
||
if (gen !== _queueGen) return;
|
||
if (!newSongs.length) return;
|
||
|
||
const recentIds = new Set(S.queue.slice(-50).map(s => s.id));
|
||
const deduped = newSongs.filter(s => !recentIds.has(s.id));
|
||
|
||
if (deduped.length === 0) {
|
||
// All similar songs recently played (small library) — try random as final fallback
|
||
if (gen !== _queueGen) return;
|
||
const rr = await api('getRandomSongs', '&size=20');
|
||
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
|
||
// If random is also exhausted, allow repeats to keep music playing
|
||
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5);
|
||
} else {
|
||
newSongs = deduped;
|
||
}
|
||
|
||
if (gen !== _queueGen) return;
|
||
S.queue.push(...newSongs);
|
||
renderQueue();
|
||
// Single-track model: songs land in S.queue; JS picks them up via nextSong(). No native push needed.
|
||
} catch {
|
||
} finally {
|
||
_radioFilling = false;
|
||
}
|
||
}
|
||
|
||
// ─── PROGRESS & SCRUBBING ─────────────────────────────────────────────────
|
||
|
||
export function updateProgress() {
|
||
if(HAS_BRIDGE) return; // driven by NSPlayer.onPositionUpdate instead
|
||
if(!S.audio.duration || S._scrubbing) return;
|
||
syncMediaSessionPosition();
|
||
const rem = S.audio.duration - S.audio.currentTime;
|
||
if(rem > 0 && rem < 60 && !S._trackChanging) _preloadNext();
|
||
const p = (S.audio.currentTime / S.audio.duration) * 100;
|
||
document.getElementById('progress-fill').style.width = p + '%';
|
||
document.getElementById('time-cur').textContent = formatDur(S.audio.currentTime);
|
||
document.getElementById('time-total').textContent = formatDur(S.audio.duration);
|
||
if(S.npOpen) {
|
||
const fill = document.getElementById('np-fill');
|
||
const cur = document.getElementById('np-cur');
|
||
const tot = document.getElementById('np-tot');
|
||
if(fill) fill.style.width = p + '%';
|
||
if(cur) cur.textContent = formatDur(S.audio.currentTime);
|
||
if(tot) tot.textContent = formatDur(S.audio.duration);
|
||
if(S.npLyricsMode) updateLyricsHighlight(S.audio.currentTime * 1000);
|
||
}
|
||
}
|
||
|
||
export function seekTo(e) {
|
||
const bar = document.getElementById('progress-bar');
|
||
const dur = _dur();
|
||
if (!bar || !dur) return;
|
||
const rect = bar.getBoundingClientRect();
|
||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||
const fill = document.getElementById('progress-fill');
|
||
if (fill) { fill.style.transition = 'none'; fill.style.width = (pos * 100) + '%'; }
|
||
S._scrubbing = true;
|
||
if (HAS_BRIDGE) {
|
||
_nsBridge('seekTo', Math.round(pos * _lastDurationMs));
|
||
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
|
||
setTimeout(done, 500);
|
||
} else {
|
||
S.audio.currentTime = pos * dur;
|
||
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { if (fill) fill.style.transition = ''; }); };
|
||
S.audio.addEventListener('seeked', done, { once: true });
|
||
setTimeout(done, 1500);
|
||
}
|
||
}
|
||
|
||
export function initProgressScrub() {
|
||
// Bottom mini-bar: mouse-only (touch disabled — too easy to accidentally seek on mobile).
|
||
_initBarScrub(
|
||
document.getElementById('progress-bar'),
|
||
document.getElementById('progress-fill'),
|
||
pos => { document.getElementById('time-cur').textContent = formatDur(pos * _dur()); },
|
||
/* noTouch= */ true
|
||
);
|
||
_initBarScrub(
|
||
document.getElementById('np-bar'),
|
||
document.getElementById('np-fill'),
|
||
pos => { const el = document.getElementById('np-cur'); if(el) el.textContent = formatDur(pos * _dur()); }
|
||
);
|
||
}
|
||
|
||
// Export for lyrics / other callers that need to seek by ms.
|
||
export function seekToMs(ms) {
|
||
if (HAS_BRIDGE) {
|
||
_nsBridge('seekTo', Math.round(ms));
|
||
} else {
|
||
S.audio.currentTime = ms / 1000;
|
||
}
|
||
}
|
||
|
||
// ─── DESKTOP KEYBOARD SHORTCUTS ───
|
||
// Space = play/pause · ←/→ = seek 5s · Shift+←/→ = prev/next · ↑/↓ = volume.
|
||
// Skipped while typing in an input so it never hijacks the search/playlist fields.
|
||
export function initKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.ctrlKey || e.altKey || e.metaKey) return; // leave OS/browser combos alone
|
||
const t = e.target;
|
||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||
t.tagName === 'SELECT' || t.isContentEditable)) return;
|
||
|
||
switch (e.code) {
|
||
case 'Space':
|
||
// Don't hijack Space when a button/link is focused — it would double-fire.
|
||
if (t && (t.tagName === 'BUTTON' || t.tagName === 'A' ||
|
||
t.closest?.('button, a, [role="button"]'))) return;
|
||
e.preventDefault();
|
||
togglePlay();
|
||
break;
|
||
case 'ArrowRight':
|
||
e.preventDefault();
|
||
if (e.shiftKey) nextSong(); else _seekBy(5);
|
||
break;
|
||
case 'ArrowLeft':
|
||
e.preventDefault();
|
||
if (e.shiftKey) prevSong(); else _seekBy(-5);
|
||
break;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
_bumpVolume(5);
|
||
break;
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
_bumpVolume(-5);
|
||
break;
|
||
}
|
||
});
|
||
}
|
||
|
||
function _seekBy(deltaSec) {
|
||
const dur = _dur();
|
||
if (!dur) return;
|
||
seekToMs(Math.max(0, Math.min(dur, _pos() + deltaSec)) * 1000);
|
||
}
|
||
|
||
function _bumpVolume(deltaSlider) {
|
||
// setVolume takes a 0–100 slider value (squared internally); recover it from the actual volume.
|
||
const cur = Math.round(Math.sqrt(Math.max(0, S.audio.volume || 0)) * 100);
|
||
setVolume(Math.max(0, Math.min(100, cur + deltaSlider)));
|
||
}
|
||
|
||
function _initBarScrub(bar, fill, onMove, noTouch = false) {
|
||
if(!bar || !fill) return;
|
||
function getPos(clientX) { const r = bar.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX-r.left)/r.width)); }
|
||
function startScrub(pos) { S._scrubbing=true; fill.style.transition='none'; fill.style.width=(pos*100)+'%'; onMove(pos); }
|
||
function moveScrub(pos) { fill.style.width=(pos*100)+'%'; onMove(pos); }
|
||
function endScrub(pos) {
|
||
fill.style.width = (pos * 100) + '%';
|
||
const dur = _dur();
|
||
if (HAS_BRIDGE) {
|
||
_nsBridge('seekTo', Math.round(pos * _lastDurationMs));
|
||
S._scrubbing = false;
|
||
requestAnimationFrame(() => { fill.style.transition = ''; });
|
||
} else if (dur) {
|
||
S.audio.currentTime = pos * dur;
|
||
const done = () => { S._scrubbing = false; requestAnimationFrame(() => { fill.style.transition = ''; }); };
|
||
S.audio.addEventListener('seeked', done, { once: true });
|
||
setTimeout(done, 1500);
|
||
} else {
|
||
S._scrubbing = false;
|
||
fill.style.transition = '';
|
||
}
|
||
}
|
||
if (noTouch) {
|
||
// Block all touch input (including synthetic mousedown the browser fires after a tap).
|
||
bar.addEventListener('touchstart', e => { e.preventDefault(); }, { passive: false });
|
||
} else {
|
||
// Touch: require ≥8px horizontal drag before committing to scrub.
|
||
// A plain tap cancels cleanly without seeking.
|
||
let _touchStartX = 0, _touchMoved = false;
|
||
bar.addEventListener('touchstart', e => {
|
||
_touchStartX = e.touches[0].clientX;
|
||
_touchMoved = false;
|
||
e.stopPropagation();
|
||
e.preventDefault(); // block synthetic mousedown/click that would trigger scrub on plain tap
|
||
});
|
||
bar.addEventListener('touchmove', e => {
|
||
const dx = Math.abs(e.touches[0].clientX - _touchStartX);
|
||
if (!_touchMoved && dx >= 8) { _touchMoved = true; startScrub(getPos(e.touches[0].clientX)); }
|
||
if (_touchMoved && S._scrubbing) moveScrub(getPos(e.touches[0].clientX));
|
||
}, {passive:true});
|
||
bar.addEventListener('touchend', e => {
|
||
if (_touchMoved && S._scrubbing) endScrub(getPos(e.changedTouches[0].clientX));
|
||
else S._scrubbing = false;
|
||
_touchMoved = false;
|
||
}, {passive:true});
|
||
}
|
||
bar.addEventListener('pointerdown', e => {
|
||
if (noTouch && e.pointerType === 'touch') return; // bottom bar: mouse/stylus only
|
||
startScrub(getPos(e.clientX)); e.preventDefault();
|
||
const mm = e2 => { if(S._scrubbing) moveScrub(getPos(e2.clientX)); };
|
||
const mu = e2 => { if(S._scrubbing) endScrub(getPos(e2.clientX)); document.removeEventListener('pointermove',mm); document.removeEventListener('pointerup',mu); };
|
||
document.addEventListener('pointermove', mm);
|
||
document.addEventListener('pointerup', mu);
|
||
});
|
||
}
|
||
|
||
export function setVolume(v) {
|
||
// Square curve: slider 50 → actual 0.25, slider 30 → actual 0.09
|
||
// Matches how ears perceive loudness (logarithmic) so low values feel genuinely quiet.
|
||
const vol = Math.pow(v / 100, 2);
|
||
S.audio.volume = vol;
|
||
S.previewAudio.volume = vol;
|
||
const absAudio = document.getElementById('abs-audio');
|
||
if (absAudio) absAudio.volume = vol;
|
||
document.querySelectorAll('.volume-slider-sync').forEach(el => { el.value = v; });
|
||
if (HAS_BRIDGE) _nsBridge('setVolume', vol);
|
||
try { localStorage.setItem('ns_volume', v); } catch (_) {}
|
||
}
|
||
|
||
// ─── MEDIA SESSION ────────────────────────────────────────────────────────
|
||
|
||
let _msHandlersSet = false;
|
||
export function updateMediaSession(s) {
|
||
// When native is active, Media3 owns the system notification — don't touch it.
|
||
if (HAS_BRIDGE) return;
|
||
const ms = navigator.mediaSession;
|
||
if(!ms) return;
|
||
const artwork = s.coverArt ? [
|
||
{ src: _cachedCoverUrl(s.coverArt, 256), sizes:'256x256', type:'image/jpeg' },
|
||
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
|
||
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
|
||
] : [];
|
||
// Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das
|
||
// Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS
|
||
// aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die
|
||
// erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues
|
||
// Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird).
|
||
const wechsel = !ms.metadata
|
||
|| ms.metadata.title !== s.title
|
||
|| ms.metadata.artist !== (s.artist || '')
|
||
|| ms.metadata.album !== (s.album || '');
|
||
if (wechsel) {
|
||
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
|
||
} else {
|
||
ms.metadata.artwork = artwork;
|
||
}
|
||
// Action handlers are stable — set them once, not on every track change.
|
||
if (_msHandlersSet) return;
|
||
_msHandlersSet = true;
|
||
ms.setActionHandler('play', togglePlay);
|
||
ms.setActionHandler('pause', togglePlay);
|
||
ms.setActionHandler('previoustrack', prevSong);
|
||
ms.setActionHandler('nexttrack', nextSong);
|
||
ms.setActionHandler('seekbackward', d => { S.audio.currentTime = Math.max(0, S.audio.currentTime-(d.seekOffset||10)); });
|
||
ms.setActionHandler('seekforward', d => { S.audio.currentTime = Math.min(S.audio.duration||0, S.audio.currentTime+(d.seekOffset||10)); });
|
||
ms.setActionHandler('seekto', d => { if(d.seekTime!=null) S.audio.currentTime = d.seekTime; });
|
||
}
|
||
|
||
export function syncMediaSessionPosition() {
|
||
if (HAS_BRIDGE) return;
|
||
if(!navigator.mediaSession?.setPositionState || !S.audio.duration || isNaN(S.audio.duration)) return;
|
||
const now = Date.now();
|
||
if(now - S._msPosLast < 1000) return;
|
||
S._msPosLast = now;
|
||
try { navigator.mediaSession.setPositionState({ duration:S.audio.duration, playbackRate:S.audio.playbackRate||1, position:Math.min(S.audio.currentTime, S.audio.duration) }); } catch {}
|
||
}
|
||
|
||
// ─── STAR ─────────────────────────────────────────────────────────────────
|
||
|
||
export async function toggleStar(song, btn) {
|
||
try {
|
||
await api(song._starred ? 'unstar' : 'star', `&id=${song.id}`);
|
||
song._starred = !song._starred;
|
||
if(btn) {
|
||
btn.classList.toggle('starred', song._starred);
|
||
btn.querySelector('svg')?.setAttribute('fill', song._starred ? 'currentColor' : 'none');
|
||
}
|
||
if(S.queue[S.qIdx]?.id === song.id) updateStarBtn();
|
||
} catch(e) { toast('Fehler: ' + e.message); }
|
||
}
|
||
|
||
export function starCurrent() {
|
||
if(S.qIdx < 0 || !S.queue[S.qIdx]) return;
|
||
toggleStar(S.queue[S.qIdx], document.getElementById('btn-star-current'));
|
||
}
|
||
|
||
export function updateStarBtn() {
|
||
const s = S.queue[S.qIdx];
|
||
const icon = document.getElementById('star-icon');
|
||
if(!icon) return;
|
||
const isStarred = s && (s._starred || s.starred);
|
||
icon.setAttribute('fill', isStarred ? 'var(--accent)' : 'none');
|
||
icon.setAttribute('stroke', isStarred ? 'var(--accent)' : 'var(--text3)');
|
||
}
|
||
|
||
export function syncNpStar() {
|
||
if(!S.npOpen) return;
|
||
const s = S.queue[S.qIdx];
|
||
const icon = document.getElementById('np-star-icon');
|
||
if(!icon || !s) return;
|
||
const starred = s._starred || s.starred;
|
||
icon.setAttribute('fill', starred ? 'var(--accent)' : 'none');
|
||
icon.setAttribute('stroke', starred ? 'var(--accent)' : 'rgba(255,255,255,.5)');
|
||
}
|
||
|
||
export function npToggleStar() {
|
||
const s = S.queue[S.qIdx];
|
||
if(!s) return;
|
||
api(s._starred || s.starred ? 'unstar' : 'star', `&id=${s.id}`)
|
||
.then(() => { s._starred = !(s._starred || s.starred); s.starred = null; syncNpStar(); updateStarBtn(); })
|
||
.catch(e => toast('Fehler: ' + e.message));
|
||
}
|
||
|
||
// ─── QUEUE PANEL ──────────────────────────────────────────────────────────
|
||
|
||
let _renderedQueueKey = '';
|
||
|
||
export function renderQueue() {
|
||
const el = document.getElementById('queue-list');
|
||
if (!el) return;
|
||
const queueKey = S.queue.map(s => s.id).join(',');
|
||
|
||
if (queueKey === _renderedQueueKey && el.children.length === S.queue.length) {
|
||
el.querySelectorAll('.queue-item').forEach((item, i) => item.classList.toggle('active', i === S.qIdx));
|
||
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
||
return;
|
||
}
|
||
|
||
_renderedQueueKey = queueKey;
|
||
el.innerHTML = '';
|
||
if (!S.queue.length) { el.innerHTML = '<div class="empty-state" style="padding:40px 16px"><div class="icon">🎵</div><p>Leer</p></div>'; return; }
|
||
S.queue.forEach((s, i) => {
|
||
const item = document.createElement('div');
|
||
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
|
||
item.dataset.qidx = i;
|
||
const cv = coverUrl(s.coverArt, 128);
|
||
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>` + QUEUE_WERKZEUGE;
|
||
item.onclick = () => { S.qIdx = i; playCurrent(); };
|
||
el.appendChild(item);
|
||
});
|
||
macheQueueBearbeitbar('queue-list');
|
||
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
|
||
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
|
||
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
|
||
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
|
||
|
||
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
|
||
export function queueEntfernen(i) {
|
||
if (i < 0 || i >= S.queue.length) return;
|
||
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
|
||
S.queue.splice(i, 1);
|
||
S.qIdx = qIdx;
|
||
_renderedQueueKey = '';
|
||
|
||
if (!S.queue.length) {
|
||
S.qIdx = -1;
|
||
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
|
||
setPlayIcon(false);
|
||
renderQueue(); renderNpQueue();
|
||
toast('Warteschlange geleert');
|
||
return;
|
||
}
|
||
if (warAktuell) playCurrent();
|
||
renderQueue(); renderNpQueue();
|
||
}
|
||
|
||
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
|
||
export function queueVerschieben(von, nach) {
|
||
const n = S.queue.length;
|
||
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
|
||
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
|
||
const [titel] = S.queue.splice(von, 1);
|
||
S.queue.splice(nach, 0, titel);
|
||
S.qIdx = qIdx;
|
||
_renderedQueueKey = '';
|
||
renderQueue(); renderNpQueue();
|
||
}
|
||
|
||
export function toggleQueue() {
|
||
S.queueOpen = !S.queueOpen;
|
||
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
|
||
if(S.queueOpen) renderQueue();
|
||
_syncOverlayState();
|
||
}
|
||
|
||
// Called after showRomaji is toggled — re-renders all live player UI with new display mode.
|
||
export function refreshDisplayMode() {
|
||
_renderedQueueKey = ''; // force queue re-render
|
||
if (S.queueOpen) renderQueue();
|
||
const s = S.queue[S.qIdx];
|
||
if (s) { updatePlayerUI(s, true); _syncNpDirect(s); }
|
||
}
|
||
|
||
// ─── DOWNLOAD ─────────────────────────────────────────────────────────────
|
||
|
||
export function dlSongLocal(s) {
|
||
const a = document.createElement('a');
|
||
a.href = downloadUrl(s.id);
|
||
a.download = `${s.artist||'Unknown'} - ${s.title}.${s.suffix||'mp3'}`;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
toast(`⬇ ${s.title}`);
|
||
}
|
||
|
||
export function downloadCurrent() {
|
||
if(S.qIdx >= 0 && S.queue[S.qIdx]) dlSongLocal(S.queue[S.qIdx]);
|
||
}
|
||
|
||
// ─── PREVIEW ──────────────────────────────────────────────────────────────
|
||
|
||
export function playPreview(track) {
|
||
if(!track.previewUrl) { toast('Kein Preview verfügbar'); return; }
|
||
const wasPlaying = HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
|
||
if(wasPlaying) {
|
||
if (HAS_BRIDGE) _nsBridge('pause');
|
||
else S.audio.pause();
|
||
}
|
||
S.previewCurrent = track;
|
||
S.previewCurrent._resumeMain = wasPlaying;
|
||
S.previewAudio.volume = S.audio.volume;
|
||
S.previewAudio.src = track.previewUrl;
|
||
S.previewAudio.play();
|
||
document.getElementById('preview-title').innerHTML = esc(track.trackName||'—') + '<span class="preview-badge">30s Preview</span>';
|
||
document.getElementById('preview-sub').textContent = (track.artistName||'') + (track.collectionName?' · '+track.collectionName:'');
|
||
const art = track.artworkUrl100 || track.artworkUrl60 || '';
|
||
document.getElementById('preview-art').innerHTML = art ? `<img src="${art}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art" style="font-size:16px">♪</div>';
|
||
document.getElementById('preview-bar').classList.add('visible');
|
||
document.getElementById('preview-fill').style.width = '0%';
|
||
}
|
||
|
||
export function stopPreview(resume) {
|
||
const shouldResume = resume !== false && S.previewCurrent?._resumeMain;
|
||
S.previewAudio.pause(); S.previewAudio.src = '';
|
||
document.getElementById('preview-bar').classList.remove('visible');
|
||
document.getElementById('preview-fill').style.width = '0%';
|
||
S.previewCurrent = null;
|
||
if (shouldResume) {
|
||
if (HAS_BRIDGE) _nsBridge('play');
|
||
else if (S.audio.src) S.audio.play();
|
||
}
|
||
}
|
||
|
||
export function updatePreviewProgress() {
|
||
if(!S.previewAudio.duration) return;
|
||
document.getElementById('preview-fill').style.width = (S.previewAudio.currentTime / S.previewAudio.duration * 100) + '%';
|
||
}
|
||
|
||
// ─── RESTORE LAST PLAYED ──────────────────────────────────────────────────
|
||
|
||
// ─── BACK-BUTTON / OVERLAY STATE SYNC ────────────────────────────────────
|
||
// Native back button: when JS has an overlay open, Android delegates the event
|
||
// to window.NSPlayer.handleBack() instead of minimising the app.
|
||
|
||
function _hasAnyOverlayOpen() {
|
||
if (S.npOpen || S.queueOpen) return true;
|
||
if (document.getElementById('np-sheet')?.classList.contains('open')) return true;
|
||
if (document.getElementById('ctx-menu')?.style.display === 'block') return true;
|
||
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) return true;
|
||
return ['settings-modal','pl-modal','info-modal','dl-modal','edit-pl-modal','confirm-modal','notif-panel']
|
||
.some(id => document.getElementById(id)?.classList.contains('open'));
|
||
}
|
||
|
||
export function _syncOverlayState() {
|
||
if (!HAS_BRIDGE) return;
|
||
try { window.NSBridge.setOverlayState(_hasAnyOverlayOpen()); } catch {}
|
||
}
|
||
|
||
window.NSPlayer.handleBack = function() {
|
||
// Close overlays from topmost layer downward.
|
||
if (document.getElementById('np-sheet')?.classList.contains('open')) {
|
||
window.closeNpMenu?.(); _syncOverlayState(); return;
|
||
}
|
||
const ctxMenu = document.getElementById('ctx-menu');
|
||
if (ctxMenu?.style.display === 'block') {
|
||
ctxMenu.style.display = 'none'; ctxMenu.innerHTML = '';
|
||
_syncOverlayState(); return;
|
||
}
|
||
if (S.npOpen) {
|
||
window.closeNowPlaying?.(); _syncOverlayState(); return;
|
||
}
|
||
if (S.queueOpen) {
|
||
toggleQueue(); _syncOverlayState(); return;
|
||
}
|
||
// Generic modals
|
||
const modalMap = {
|
||
'settings-modal': 'closeSettings',
|
||
'pl-modal': 'closePlModal',
|
||
'info-modal': 'closeInfoModal',
|
||
'dl-modal': 'closeDlModal',
|
||
'edit-pl-modal': 'closeEditPlModal',
|
||
'confirm-modal': '_confirmCancel',
|
||
};
|
||
for (const [id, fn] of Object.entries(modalMap)) {
|
||
const el = document.getElementById(id);
|
||
if (el?.classList.contains('open')) {
|
||
if (window[fn]) window[fn](); else el.classList.remove('open');
|
||
_syncOverlayState(); return;
|
||
}
|
||
}
|
||
// Notif panel (generic fallback)
|
||
const notifPanel = document.getElementById('notif-panel');
|
||
if (notifPanel?.classList.contains('open')) {
|
||
notifPanel.classList.remove('open'); _syncOverlayState(); return;
|
||
}
|
||
// Sidebar
|
||
if (document.getElementById('sidebar')?.classList.contains('mobile-open')) {
|
||
window.closeSidebar?.(); _syncOverlayState(); return;
|
||
}
|
||
};
|
||
|
||
// MutationObserver keeps hasOpenOverlay in sync whenever the DOM changes class
|
||
// attributes (e.g. any overlay gains/loses the 'open' class).
|
||
if (HAS_BRIDGE) {
|
||
const _startOverlayObserver = () => {
|
||
if (!document.body) return;
|
||
new MutationObserver(_syncOverlayState).observe(document.body, {
|
||
attributes: true, attributeFilter: ['class'], subtree: true,
|
||
});
|
||
};
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', _startOverlayObserver);
|
||
} else {
|
||
_startOverlayObserver();
|
||
}
|
||
}
|
||
|
||
// Immediately sync JS track/play state with the native bridge snapshot.
|
||
// Called on app foreground so the UI is correct before any deferred evaluateJavascript callbacks fire.
|
||
export function reconcileNativeState() {
|
||
if (!HAS_BRIDGE) return;
|
||
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(); }
|
||
}
|
||
}
|
||
if (typeof snap.isPlaying === 'boolean' && snap.isPlaying !== _nativePlaying) {
|
||
_nativePlaying = snap.isPlaying;
|
||
setPlayIcon(snap.isPlaying);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
export function _restoreLastPlayed() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
|
||
if(!saved || !saved.song) return;
|
||
S.queue = saved.queue || [saved.song];
|
||
S.qIdx = saved.qIdx || 0;
|
||
const s = S.queue[S.qIdx];
|
||
if(!s) return;
|
||
document.getElementById('player-title').textContent = dispTitle(s) || s.title || '—';
|
||
const artistParts = dispArtistParts(s, parseArtists);
|
||
document.getElementById('player-artist-name').innerHTML = artistParts.length
|
||
? artistParts.map(a => `<span style="cursor:pointer" onclick="s_goArtist(event,'${esc(a.originalName||a.name)}','${a.id||''}')">${esc(a.name)}</span>`)
|
||
.join('<span style="color:var(--text3)"> • </span>')
|
||
: '—';
|
||
document.getElementById('player-album-name').textContent = dispAlbum(s);
|
||
const art = document.getElementById('player-art');
|
||
const cv = coverUrl(s.coverArt, 256);
|
||
art.innerHTML = cv ? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">` : '<div class="no-art">♪</div>';
|
||
updateStarBtn();
|
||
document.title = `${s.title} — NovaSonic`;
|
||
if (HAS_BRIDGE) return; // native will sync state via NSPlayer.onReady
|
||
// Web path: prime the audio element so play button works immediately.
|
||
S.audio.src = streamUrl(s.id);
|
||
S.audio.load();
|
||
|
||
} catch {}
|
||
}
|