feat: timer scrobble (12s), artist topsong star+menu, play count toggle

Scrobbling:
- Replace end-of-track scrobble with 12s timer approach
- Android: schedule on onTrackChanged, cancel on pause
- Web: schedule in playCurrent, cancel in togglePlay pause + onEnded
- Skip/new track resets the timer automatically (new song ID)

Artist top songs:
- Add star button and 3-dots context menu to every top song row
- Topsong grid expanded to 5 columns to fit action buttons

Settings — play count toggle:
- New "Bibliothek" section with toggle switch
- When on: shows play count in album/playlist song rows
- Artist page always shows play count regardless of toggle
- Persisted in ns_cfg localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-21 09:35:26 +02:00
1 parent f79817410b
commit 7e07fef7ba
7 files changed
+57 -13

No files matched your search

+27 -7
View File
@@ -17,6 +17,24 @@ let _lastDurationMs = 0; // ms — set by onPositionUpdate, used for seek calc
let _skipping = false; // true briefly after a skip — suppresses the transient isPlaying:false
let _skipTimer = null;
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(id) {
if (_scrobbleId === String(id)) return; // already scheduled for this track
_cancelScrobble();
_scrobbleId = String(id);
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
_scrobbleId = null;
}, 12_000);
}
function _cancelScrobble() {
clearTimeout(_scrobbleTimer);
_scrobbleTimer = null;
_scrobbleId = null;
}
// 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);
@@ -142,6 +160,7 @@ window.NSPlayer = {
}
const prev = _nativePlaying;
_nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
if (state.isPlaying && S.previewCurrent) stopPreview(false);
if (state.isPlaying !== prev) {
setPlayIcon(state.isPlaying);
@@ -189,10 +208,6 @@ window.NSPlayer = {
// doesn't match what native is actually playing, the queues are mismatched.
// Allow through if reason='queue' (we triggered a setQueue) or if no IDs to compare.
if (liveId && S.queue[idx]?.id && String(liveId) !== String(S.queue[idx].id) && p.reason !== 'queue') return;
// Scrobble the track that just finished (auto-advance = it played to completion).
if (p.reason === 'auto' && S.queue[S.qIdx]?.id) {
api('scrobble', `&id=${S.queue[S.qIdx].id}&time=${Date.now()}`).catch(() => {});
}
const indexChanged = (idx !== S.qIdx);
S.qIdx = idx;
const s = S.queue[S.qIdx];
@@ -200,6 +215,8 @@ window.NSPlayer = {
updatePlayerUI(s, true);
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();
@@ -365,6 +382,7 @@ export function playCurrent() {
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_scheduleScrobble(s.id); // 12s timer — cancelled on pause/skip
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
@@ -516,7 +534,10 @@ export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
export function togglePlay() {
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
if(S.audio.src) S.audio.paused ? S.audio.play() : S.audio.pause();
if(S.audio.src) {
if (S.audio.paused) { S.audio.play(); }
else { S.audio.pause(); _cancelScrobble(); }
}
}
export const isPlaying = () => HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
@@ -618,8 +639,7 @@ export function nextSong() {
export function onEnded() {
if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; }
// Scrobble the track that just finished (web path).
if (S.queue[S.qIdx]?.id) api('scrobble', `&id=${S.queue[S.qIdx].id}&time=${Date.now()}`).catch(() => {});
_cancelScrobble(); // timer irrelevant now — song already finished
nextSong();
}