From f79817410b8c0c6d7e3021d783ca8373456dacf1 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 21 May 2026 09:18:56 +0200 Subject: [PATCH] feat: scrobbling, genre grid redesign with detail view + play button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scrobble to Navidrome on auto-advance (Android) and track end (web) so type=recent and type=frequent are accurate for the hero card - Genre section: pill chips → grid cards with colored left bars - Top 15 genres shown by default (sorted by song count), expand toggle - Genre card: click → genre detail page with full song list - Genre card: hover play button → shuffle-play without navigating - Genre detail page reuses album-detail layout (back btn, stats, song list) Co-Authored-By: Claude Sonnet 4.6 --- index.html | 21 ++++++++++-- src/main.js | 4 +-- src/player.js | 6 ++++ src/views.js | 88 ++++++++++++++++++++++++++++++++++++++++++++------- styles.css | 15 ++++++--- 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index 6506c73..f17c838 100644 --- a/index.html +++ b/index.html @@ -134,8 +134,8 @@
-
Genres
-
+
Genres
+
Zuletzt gespielt
ZufälligMehr
@@ -220,6 +220,23 @@
+ +
+ +
+
🎵
+
+
Genre
+
—
+
+
+ +
+
+
+
+
+
diff --git a/src/main.js b/src/main.js index 86498b0..38c47bc 100644 --- a/src/main.js +++ b/src/main.js @@ -16,7 +16,7 @@ import { onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, - plReorderEnter, plReorderSave, plReorderCancel, + plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, } from './views.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js'; import { @@ -304,7 +304,7 @@ Object.assign(window, { openSidebar, closeSidebar, switchNavMode, showCtxMenu, // Views loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, - plReorderEnter, plReorderSave, plReorderCancel, + plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, executeSpotifyDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, diff --git a/src/player.js b/src/player.js index 005ab96..424e879 100644 --- a/src/player.js +++ b/src/player.js @@ -189,6 +189,10 @@ 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]; @@ -614,6 +618,8 @@ 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(() => {}); nextSong(); } diff --git a/src/views.js b/src/views.js index 0dc09ef..a365391 100644 --- a/src/views.js +++ b/src/views.js @@ -46,6 +46,7 @@ export function navigate(page, data=null) { if(page==='artist-detail' && data) loadArtistDetail(data); if(page==='artist-all-songs' && data) loadArtistAllSongs(data); if(page==='album-detail' && data) loadAlbumDetail(data); + if(page==='genre-detail' && data) loadGenreDetail(data); if(page==='itunes-album-detail'&& data) loadItunesAlbumDetail(data); if(page==='new-releases') loadNewReleases(); if(page==='playlists') loadPlaylists(); @@ -156,26 +157,60 @@ async function loadHeroCard() { } catch { /* hero is optional — silent fail */ } } +const _GENRE_COLORS = ['#e8445a','#3b82f6','#f97316','#22c55e','#a855f7','#06b6d4','#eab308','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#84cc16']; +function _genreColor(name) { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; + return _GENRE_COLORS[h % _GENRE_COLORS.length]; +} + +let _allGenres = []; +let _genresExpanded = false; +const GENRE_LIMIT = 15; + async function loadGenreSection() { - const el = document.getElementById('home-genres'); + const el = document.getElementById('home-genres'); + const more = document.getElementById('home-genres-more'); if (!el) return; try { const r = await api('getGenres'); - const genres = (r.genres?.genre || []) + _allGenres = (r.genres?.genre || []) .filter(g => (g.songCount || 0) > 0) .sort((a, b) => (b.songCount || 0) - (a.songCount || 0)); - el.innerHTML = ''; - genres.forEach(g => { - const chip = document.createElement('button'); - chip.className = 'genre-chip'; - chip.textContent = g.value; - chip.title = `${g.songCount} Titel`; - chip.onclick = () => playGenre(g.value); - el.appendChild(chip); - }); + _renderGenres(); + if (_allGenres.length > GENRE_LIMIT && more) more.style.display = ''; } catch { el.style.display = 'none'; } } +function _renderGenres() { + const el = document.getElementById('home-genres'); + if (!el) return; + const list = _genresExpanded ? _allGenres : _allGenres.slice(0, GENRE_LIMIT); + el.innerHTML = ''; + list.forEach(g => { + const card = document.createElement('div'); + card.className = 'genre-card'; + card.title = `${g.songCount} Titel`; + const color = _genreColor(g.value); + card.innerHTML = ` +
+
${esc(g.value)}
+ `; + card.querySelector('.genre-card-play').onclick = (e) => { e.stopPropagation(); playGenre(g.value); }; + card.onclick = () => navigate('genre-detail', g); + el.appendChild(card); + }); +} + +export function toggleAllGenres() { + _genresExpanded = !_genresExpanded; + const more = document.getElementById('home-genres-more'); + if (more) more.textContent = _genresExpanded ? 'Weniger' : 'Alle anzeigen'; + _renderGenres(); +} + async function playGenre(genre) { try { const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`); @@ -187,6 +222,37 @@ async function playGenre(genre) { } catch {} } +async function loadGenreDetail(g) { + const genre = g.value || g; + const color = _genreColor(genre); + document.getElementById('genre-detail-name').textContent = genre; + document.getElementById('genre-detail-stats').textContent = g.songCount ? `${g.songCount} Titel` : ''; + const coverWrap = document.getElementById('genre-cover-wrap'); + coverWrap.style.background = `linear-gradient(135deg, ${color}33, ${color}11)`; + coverWrap.style.borderRadius = '10px'; + coverWrap.style.border = `2px solid ${color}55`; + document.getElementById('genre-back-btn').onclick = () => _navGoBack(); + + const list = document.getElementById('genre-songs'); + list.innerHTML = '
'; + + let songs = []; + try { + const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=500`); + songs = r.songsByGenre?.song || []; + } catch { list.innerHTML = '

Fehler beim Laden

'; return; } + + document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`; + document.getElementById('btn-play-genre').onclick = () => { + const shuffled = [...songs].sort(() => Math.random() - 0.5); + playQueue(shuffled, 0); + navigate('now-playing'); + }; + + list.innerHTML = ''; + songs.forEach((s, i) => list.appendChild(makeSongItem(s, i, songs))); +} + export async function loadAlbumRow(id, method, extra) { const c = document.getElementById(id); if(!c) return; diff --git a/styles.css b/styles.css index 5ee6597..f7f1cf6 100644 --- a/styles.css +++ b/styles.css @@ -133,10 +133,17 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} .home-hero-play:hover{transform:scale(1.1)} @media(min-width:700px){.home-hero{height:200px}.home-hero-cover{width:130px;height:130px}.home-hero-title{font-size:22px}.home-hero-info{gap:6px}} -/* ── GENRE CHIPS ── */ -.genre-chips{display:flex;flex-wrap:wrap;gap:7px;padding:2px 0 4px} -.genre-chip{padding:6px 13px;border-radius:20px;background:var(--surface2);border:1px solid var(--border);font-size:12px;font-weight:600;color:var(--text2);cursor:pointer;white-space:nowrap;transition:background .15s,color .15s,border-color .15s} -.genre-chip:hover{background:var(--accent);color:#fff;border-color:var(--accent)} +/* ── GENRE GRID ── */ +.genre-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;padding:2px 0 4px} +.genre-card{display:flex;align-items:center;gap:10px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:10px 12px;cursor:pointer;transition:background .15s,border-color .15s;overflow:hidden} +.genre-card:hover{background:var(--surface3,var(--surface));border-color:var(--accent)} +.genre-card-bar{width:4px;height:32px;border-radius:3px;flex-shrink:0} +.genre-card-name{font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0} +.genre-card-play{width:28px;height:28px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;opacity:0;transition:opacity .15s,transform .15s} +.genre-card:hover .genre-card-play{opacity:1} +.genre-card-play:hover{transform:scale(1.1)} +@media(max-width:600px){.genre-card-play{opacity:1}} +@media(min-width:700px){.genre-grid{grid-template-columns:repeat(auto-fill,minmax(170px,1fr))}} /* ── SECTION ── */ .section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:24px}