diff --git a/index.html b/index.html index f17c838..acca6ff 100644 --- a/index.html +++ b/index.html @@ -616,6 +616,17 @@ +
+

+ πŸ“Š + Bibliothek +

+
+ AbspielhΓ€ufigkeit anzeigen +
+
+
Zeigt Wiedergabezahl in Album- und Playlist-Ansichten. KΓΌnstlerseite zeigt sie immer.
+

🎨 diff --git a/src/components.js b/src/components.js index 9524f22..0bb1a13 100644 --- a/src/components.js +++ b/src/components.js @@ -45,6 +45,8 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, noAlbumLink = const albumHtml = !noAlbumLink && (s.albumId||s.album) ? ` · ${esc(s.album||'')}` : ''; + const playsHtml = S.cfg.showPlayCount && s.playCount + ? `${s.playCount.toLocaleString()}` : ''; el.innerHTML = `
${idx+1}
${cv ? `` : '
β™ͺ
'}
@@ -52,7 +54,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false, noAlbumLink =
${artistHtml}${albumHtml}
- ${formatDur(s.duration)} + ${playsHtml}${formatDur(s.duration)}
diff --git a/src/player.js b/src/player.js index 424e879..945dc17 100644 --- a/src/player.js +++ b/src/player.js @@ -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(); } diff --git a/src/state.js b/src/state.js index 100293e..d0a75ff 100644 --- a/src/state.js +++ b/src/state.js @@ -1,6 +1,6 @@ // Shared mutable state β€” imported as { S } in every module export const S = { - cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '' }, + cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false }, // Playback queue: [], diff --git a/src/ui.js b/src/ui.js index 9210c97..3940e08 100644 --- a/src/ui.js +++ b/src/ui.js @@ -59,6 +59,7 @@ export function openSettings() { document.getElementById('s-pass').value = ''; document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; document.getElementById('s-backend').value = S.cfg.backendUrl || ''; + document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount); _renderThemePicker(); document.getElementById('settings-modal').classList.add('open'); checkBackend(); @@ -91,6 +92,7 @@ export function saveSettings() { if(pass) S.cfg.pass = pass; S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); 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)); closeSettings(); toast('βœ… Gespeichert'); } diff --git a/src/views.js b/src/views.js index a365391..a15463e 100644 --- a/src/views.js +++ b/src/views.js @@ -2,7 +2,7 @@ import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js'; // 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 { closeNowPlaying, closeNpMenu } from './nowplaying.js'; import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js'; @@ -396,12 +396,20 @@ export async function loadArtistDetail(artist) { topSongs.forEach((s, i) => { const cv = coverUrl(s.coverArt, 128); const row = document.createElement('div'); row.className = 'topsong-item'; + if(s.starred && !s._starred) s._starred = true; row.innerHTML = `
${i+1}
${cv ? `` : '
🎡
'}
-
${esc(s.title)}
${esc(s.album||'')}
-
${s.playCount ? s.playCount.toLocaleString() : 'β€”'}
`; +
${esc(s.title)}
${esc(s.album||'')}
+
${s.playCount ? s.playCount.toLocaleString() : 'β€”'}
+
+ + +
`; row.onclick = () => playQueue(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); }); tsEl.appendChild(list); diff --git a/styles.css b/styles.css index f7f1cf6..22ad08e 100644 --- a/styles.css +++ b/styles.css @@ -219,6 +219,7 @@ button:focus{outline:none} .song-info .song-artist{font-size:11px;color:var(--text2);margin-top:1px} .song-meta{display:flex;align-items:center;gap:6px} .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-item:hover .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.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} -.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-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)}