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

+11
View File
@@ -616,6 +616,17 @@
</div> </div>
<span id="abs-s-status" style="font-size:12px;color:var(--green);display:block;margin-top:6px;min-height:16px"></span> <span id="abs-s-status" style="font-size:12px;color:var(--green);display:block;margin-top:6px;min-height:16px"></span>
</div> </div>
<div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">📊</span>
Bibliothek
</h3>
<div class="edit-pl-toggle-row">
<span>Abspielhäufigkeit anzeigen</span>
<div id="s-show-playcount" class="toggle" onclick="this.classList.toggle('on')"></div>
</div>
<div class="hint" style="margin-top:6px">Zeigt Wiedergabezahl in Album- und Playlist-Ansichten. Künstlerseite zeigt sie immer.</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px"> <h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🎨</span> <span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🎨</span>
+3 -1
View File
@@ -45,6 +45,8 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, noAlbumLink =
const albumHtml = !noAlbumLink && (s.albumId||s.album) const albumHtml = !noAlbumLink && (s.albumId||s.album)
? `<span style="color:var(--text3)"> · </span><span class="song-link" onclick="event.stopPropagation();s_goAlbum(event,'${s.albumId||''}','${esc(s.album||'')}','${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.album||'')}</span>` ? `<span style="color:var(--text3)"> · </span><span class="song-link" onclick="event.stopPropagation();s_goAlbum(event,'${s.albumId||''}','${esc(s.album||'')}','${esc(s.artist||'')}','${s.artistId||''}')">${esc(s.album||'')}</span>`
: ''; : '';
const playsHtml = S.cfg.showPlayCount && s.playCount
? `<span class="song-plays">${s.playCount.toLocaleString()}</span>` : '';
el.innerHTML = `<div class="song-num">${idx+1}</div> el.innerHTML = `<div class="song-num">${idx+1}</div>
<div class="song-thumb">${cv ? `<img src="${cv}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>♪</div>'">` : '<div class="no-art">♪</div>'}</div> <div class="song-thumb">${cv ? `<img src="${cv}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>♪</div>'">` : '<div class="no-art">♪</div>'}</div>
<div class="song-info"> <div class="song-info">
@@ -52,7 +54,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, noAlbumLink =
<div class="song-artist">${artistHtml}${albumHtml}</div> <div class="song-artist">${artistHtml}${albumHtml}</div>
</div> </div>
<div class="song-meta"> <div class="song-meta">
<span class="song-dur">${formatDur(s.duration)}</span> ${playsHtml}<span class="song-dur">${formatDur(s.duration)}</span>
<div class="song-actions"> <div class="song-actions">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button> <button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr Optionen"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button> <button class="icon-btn song-menu-btn" title="Mehr Optionen"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
+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 _skipping = false; // true briefly after a skip — suppresses the transient isPlaying:false
let _skipTimer = null; 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, // Unified duration / position / seek helpers — use native state when available,
// fall back to HTMLAudioElement when not. // fall back to HTMLAudioElement when not.
const _dur = () => HAS_BRIDGE ? _lastDurationMs / 1000 : (S.audio.duration || 0); const _dur = () => HAS_BRIDGE ? _lastDurationMs / 1000 : (S.audio.duration || 0);
@@ -142,6 +160,7 @@ window.NSPlayer = {
} }
const prev = _nativePlaying; const prev = _nativePlaying;
_nativePlaying = state.isPlaying; _nativePlaying = state.isPlaying;
if (!state.isPlaying && !state.isBuffering) _cancelScrobble();
if (state.isPlaying && S.previewCurrent) stopPreview(false); if (state.isPlaying && S.previewCurrent) stopPreview(false);
if (state.isPlaying !== prev) { if (state.isPlaying !== prev) {
setPlayIcon(state.isPlaying); setPlayIcon(state.isPlaying);
@@ -189,10 +208,6 @@ window.NSPlayer = {
// doesn't match what native is actually playing, the queues are mismatched. // 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. // 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; 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); const indexChanged = (idx !== S.qIdx);
S.qIdx = idx; S.qIdx = idx;
const s = S.queue[S.qIdx]; const s = S.queue[S.qIdx];
@@ -200,6 +215,8 @@ window.NSPlayer = {
updatePlayerUI(s, true); updatePlayerUI(s, true);
onNpSongChange(s.id); onNpSongChange(s.id);
if (indexChanged) renderQueue(); if (indexChanged) renderQueue();
// Schedule scrobble — fires after 12s of uninterrupted playback.
_scheduleScrobble(s.id);
// Radio mode: top up the queue when running low. // Radio mode: top up the queue when running low.
// fillRadioQueue sends addToQueue to native internally — no re-send here. // fillRadioQueue sends addToQueue to native internally — no re-send here.
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue(); if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
@@ -365,6 +382,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
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id); const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload(); _clearPreload();
@@ -516,7 +534,10 @@ export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
export function togglePlay() { export function togglePlay() {
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; } 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; export const isPlaying = () => HAS_BRIDGE ? _nativePlaying : !S.audio.paused;
@@ -618,8 +639,7 @@ 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; }
// Scrobble the track that just finished (web path). _cancelScrobble(); // timer irrelevant now — song already finished
if (S.queue[S.qIdx]?.id) api('scrobble', `&id=${S.queue[S.qIdx].id}&time=${Date.now()}`).catch(() => {});
nextSong(); nextSong();
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module // Shared mutable state — imported as { S } in every module
export const S = { export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '' }, cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false },
// Playback // Playback
queue: [], queue: [],
+2
View File
@@ -59,6 +59,7 @@ export function openSettings() {
document.getElementById('s-pass').value = ''; document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || ''; document.getElementById('s-backend').value = S.cfg.backendUrl || '';
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
_renderThemePicker(); _renderThemePicker();
document.getElementById('settings-modal').classList.add('open'); document.getElementById('settings-modal').classList.add('open');
checkBackend(); checkBackend();
@@ -91,6 +92,7 @@ export function saveSettings() {
if(pass) S.cfg.pass = pass; if(pass) S.cfg.pass = pass;
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,''); S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert'); closeSettings(); toast('✅ Gespeichert');
} }
+11 -3
View File
@@ -2,7 +2,7 @@ import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js'; import { esc, formatDur, toast } from './utils.js';
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js'; import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js';
// Circular imports — safe (only used inside function bodies) // Circular imports — safe (only used inside function bodies)
import { playQueue, dlSongLocal, playPreview } from './player.js'; import { playQueue, dlSongLocal, playPreview, toggleStar } from './player.js';
import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from './ui.js'; import { showCtxMenu, openEditPlModal, openDlModal, closeSidebar, switchNavMode } from './ui.js';
import { closeNowPlaying, closeNpMenu } from './nowplaying.js'; import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js'; import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
@@ -396,12 +396,20 @@ export async function loadArtistDetail(artist) {
topSongs.forEach((s, i) => { topSongs.forEach((s, i) => {
const cv = coverUrl(s.coverArt, 128); const cv = coverUrl(s.coverArt, 128);
const row = document.createElement('div'); row.className = 'topsong-item'; const row = document.createElement('div'); row.className = 'topsong-item';
if(s.starred && !s._starred) s._starred = true;
row.innerHTML = `<div class="topsong-num">${i+1}</div> row.innerHTML = `<div class="topsong-num">${i+1}</div>
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div> <div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
<div><div class="topsong-title">${esc(s.title)}</div><div style="font-size:11px;color:var(--text2)">${esc(s.album||'')}</div></div> <div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div><div style="font-size:11px;color:var(--text2)">${esc(s.album||'')}</div></div>
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>`; <div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>
<div class="song-actions" style="flex-shrink:0">
<button class="icon-btn${s._starred?' starred':''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred?'currentColor':'none'}" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="icon-btn song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>`;
row.onclick = () => playQueue(topSongs, i); row.onclick = () => playQueue(topSongs, i);
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, topSongs, i); }); row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, topSongs, i); });
const btns = row.querySelectorAll('.icon-btn');
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, topSongs, i); };
list.appendChild(row); list.appendChild(row);
}); });
tsEl.appendChild(list); tsEl.appendChild(list);
+2 -1
View File
@@ -219,6 +219,7 @@ button:focus{outline:none}
.song-info .song-artist{font-size:11px;color:var(--text2);margin-top:1px} .song-info .song-artist{font-size:11px;color:var(--text2);margin-top:1px}
.song-meta{display:flex;align-items:center;gap:6px} .song-meta{display:flex;align-items:center;gap:6px}
.song-dur{font-size:11px;color:var(--text3);font-family:var(--mono)} .song-dur{font-size:11px;color:var(--text3);font-family:var(--mono)}
.song-plays{font-size:11px;color:var(--text3);font-family:var(--mono)}
.song-actions{display:flex;gap:2px;opacity:0;transition:opacity .15s} .song-actions{display:flex;gap:2px;opacity:0;transition:opacity .15s}
.song-item:hover .song-actions{opacity:1} .song-item:hover .song-actions{opacity:1}
@media (hover:none),(pointer:coarse){.song-actions{opacity:1}} @media (hover:none),(pointer:coarse){.song-actions{opacity:1}}
@@ -768,7 +769,7 @@ button:focus{outline:none}
#spotify-enrich-status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:8px 12px;margin-bottom:8px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;transition:opacity .4s} #spotify-enrich-status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:8px 12px;margin-bottom:8px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;transition:opacity .4s}
#spotify-enrich-status.done{color:var(--accent)} #spotify-enrich-status.done{color:var(--accent)}
#spotify-enrich-status .enrich-spinner{width:12px;height:12px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0} #spotify-enrich-status .enrich-spinner{width:12px;height:12px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0}
.topsong-item{display:grid;grid-template-columns:28px 44px 1fr auto;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;cursor:pointer;transition:background .15s} .topsong-item{display:grid;grid-template-columns:28px 44px 1fr auto auto;align-items:center;gap:10px;padding:7px 10px;border-radius:8px;cursor:pointer;transition:background .15s}
.topsong-item:hover{background:var(--surface)} .topsong-item:hover{background:var(--surface)}
.topsong-num{font-size:11px;color:var(--text3);font-family:var(--mono);text-align:center} .topsong-num{font-size:11px;color:var(--text3);font-family:var(--mono);text-align:center}
.topsong-thumb{width:44px;height:44px;border-radius:6px;overflow:hidden;background:var(--surface2)} .topsong-thumb{width:44px;height:44px;border-radius:6px;overflow:hidden;background:var(--surface2)}