feat: configurable scrobble timing (15s / 30s / 1/3 / 2/3 / end)

Settings dropdown: 15s (default), 30s, 1/3 of duration, 2/3 of duration, song end.
Duration-based modes use s.duration from Navidrome metadata (always available).
'end' mode skips the timer and fires the scrobble in onEnded (web) / onQueueEnded (native).
Skipping a song never scrobbles in any mode — _cancelScrobble() still runs on skip/pause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-07 17:53:02 +02:00
1 parent acb15d8c00
commit 6391857fda
3 files changed
+33 -6

No files matched your search

+10
View File
@@ -661,6 +661,16 @@
<span>Romaji anzeigen</span> <span>Romaji anzeigen</span>
<div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div> <div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div>
</div> </div>
<div class="edit-pl-toggle-row" style="margin-top:12px">
<span>Scrobble senden nach</span>
<select id="s-scrobble-mode" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-family:var(--font);font-size:13px;cursor:pointer">
<option value="15s">15 Sekunden</option>
<option value="30s">30 Sekunden</option>
<option value="1/3">1/3 der Song-Dauer</option>
<option value="2/3">2/3 der Song-Dauer</option>
<option value="end">Song-Ende</option>
</select>
</div>
</div> </div>
<div class="settings-section"> <div class="settings-section">
<div style="display:flex;align-items:center;justify-content:space-between"> <div style="display:flex;align-items:center;justify-content:space-between">
+21 -6
View File
@@ -20,20 +20,33 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
// ─── SCROBBLE ───────────────────────────────────────────────────────────── // ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null; let _scrobbleTimer = null;
let _scrobbleId = null; let _scrobbleId = null;
function _scheduleScrobble(id) { function _scheduleScrobble(id, durSec) {
if (_scrobbleId === String(id)) return; // already scheduled for this track if (_scrobbleId === String(id)) return;
_cancelScrobble(); _cancelScrobble();
_scrobbleId = String(id); _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(() => { _scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {}); if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
_scrobbleId = null; _scrobbleId = null;
}, 15_000); }, delay);
} }
function _cancelScrobble() { function _cancelScrobble() {
clearTimeout(_scrobbleTimer); clearTimeout(_scrobbleTimer);
_scrobbleTimer = null; _scrobbleTimer = null;
_scrobbleId = 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, // Unified duration / position / seek helpers — use native state when available,
// fall back to HTMLAudioElement when not. // fall back to HTMLAudioElement when not.
@@ -192,7 +205,7 @@ window.NSPlayer = {
try { try {
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
if (!s) return; if (!s) return;
_scheduleScrobble(s.id); _scheduleScrobble(s.id, s.duration);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {} } catch (e) {}
}, },
@@ -230,6 +243,7 @@ window.NSPlayer = {
if (!_bridgeReady || Date.now() - _readyTs < 1500) return; if (!_bridgeReady || Date.now() - _readyTs < 1500) return;
_nativePlaying = false; _nativePlaying = false;
setPlayIcon(false); setPlayIcon(false);
if (S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
onEnded(); // handles repeat, radio top-up, and queue advance onEnded(); // handles repeat, radio top-up, and queue advance
}, },
@@ -376,7 +390,7 @@ export function playCurrent() {
updatePlayerUI(s, true); updatePlayerUI(s, true);
onNpSongChange(s.id); onNpSongChange(s.id);
renderQueue(); renderQueue();
_scheduleScrobble(s.id); // 12s timer — cancelled on pause/skip _scheduleScrobble(s.id, s.duration);
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload(); _clearPreload();
@@ -691,7 +705,8 @@ export function nextSong() {
export function onEnded() { export function onEnded() {
if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; } if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; }
_cancelScrobble(); // timer irrelevant now — song already finished if (!HAS_BRIDGE && S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
_cancelScrobble();
nextSong(); nextSong();
} }
+2
View File
@@ -50,6 +50,7 @@ export function openSettings() {
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount); document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji); document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg); document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg);
document.getElementById('s-scrobble-mode').value = S.cfg.scrobbleMode || '15s';
_renderThemePicker(); _renderThemePicker();
document.getElementById('settings-modal').classList.add('open'); document.getElementById('settings-modal').classList.add('open');
checkBackend(); checkBackend();
@@ -131,6 +132,7 @@ export function saveSettings() {
const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on'); const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on');
S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on'); S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on');
S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on'); S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on');
S.cfg.scrobbleMode = document.getElementById('s-scrobble-mode').value || '15s';
updateNpColorMode(); updateNpColorMode();
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert'); closeSettings(); toast('✅ Gespeichert');