feat: scrobbling, genre grid redesign with detail view + play button

- 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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-21 09:18:56 +02:00
1 parent d3a1bf6a82
commit f79817410b
5 files changed
+115 -19

No files matched your search

+19 -2
View File
@@ -134,8 +134,8 @@
<div class="page active" id="page-home"> <div class="page active" id="page-home">
<div id="home-greeting" class="home-greeting"></div> <div id="home-greeting" class="home-greeting"></div>
<div id="home-hero" class="home-hero"></div> <div id="home-hero" class="home-hero"></div>
<div class="section-header" style="margin-top:20px"><span class="section-title">Genres</span></div> <div class="section-header" style="margin-top:20px"><span class="section-title">Genres</span><span class="section-more" id="home-genres-more" onclick="toggleAllGenres()" style="display:none">Alle anzeigen</span></div>
<div id="home-genres" class="genre-chips"></div> <div id="home-genres" class="genre-grid"></div>
<div class="section-header" style="margin-top:20px"><span class="section-title">Zuletzt gespielt</span></div> <div class="section-header" style="margin-top:20px"><span class="section-title">Zuletzt gespielt</span></div>
<div class="scroll-row" id="home-recent"></div> <div class="scroll-row" id="home-recent"></div>
<div class="section-header"><span class="section-title">Zufällig</span><span class="section-more" onclick="navigate('random')">Mehr</span></div> <div class="section-header"><span class="section-title">Zufällig</span><span class="section-more" onclick="navigate('random')">Mehr</span></div>
@@ -220,6 +220,23 @@
<div class="song-list" id="album-songs"></div> <div class="song-list" id="album-songs"></div>
</div> </div>
<!-- GENRE DETAIL -->
<div class="page" id="page-genre-detail">
<button class="back-btn" id="genre-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div class="album-header">
<div class="album-cover" id="genre-cover-wrap" style="display:flex;align-items:center;justify-content:center;font-size:40px">🎵</div>
<div class="album-meta">
<div class="album-type">Genre</div>
<div class="album-name" id="genre-detail-name">—</div>
<div class="album-stats" id="genre-detail-stats"></div>
<div class="album-actions">
<button class="btn-play-all" id="btn-play-genre"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Shuffle</button>
</div>
</div>
</div>
<div class="song-list" id="genre-songs"></div>
</div>
<!-- STARRED --> <!-- STARRED -->
<div class="page" id="page-starred"> <div class="page" id="page-starred">
<div class="page-header"><div class="page-title">Favoriten ⭐</div></div> <div class="page-header"><div class="page-title">Favoriten ⭐</div></div>
+2 -2
View File
@@ -16,7 +16,7 @@ import {
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views.js'; } from './views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js';
import { import {
@@ -304,7 +304,7 @@ Object.assign(window, {
openSidebar, closeSidebar, switchNavMode, showCtxMenu, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
// Views // Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
executeSpotifyDownload, executeSpotifyDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
+6
View File
@@ -189,6 +189,10 @@ 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];
@@ -614,6 +618,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; }
// 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(); nextSong();
} }
+77 -11
View File
@@ -46,6 +46,7 @@ export function navigate(page, data=null) {
if(page==='artist-detail' && data) loadArtistDetail(data); if(page==='artist-detail' && data) loadArtistDetail(data);
if(page==='artist-all-songs' && data) loadArtistAllSongs(data); if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
if(page==='album-detail' && data) loadAlbumDetail(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==='itunes-album-detail'&& data) loadItunesAlbumDetail(data);
if(page==='new-releases') loadNewReleases(); if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists(); if(page==='playlists') loadPlaylists();
@@ -156,26 +157,60 @@ async function loadHeroCard() {
} catch { /* hero is optional — silent fail */ } } 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() { 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; if (!el) return;
try { try {
const r = await api('getGenres'); const r = await api('getGenres');
const genres = (r.genres?.genre || []) _allGenres = (r.genres?.genre || [])
.filter(g => (g.songCount || 0) > 0) .filter(g => (g.songCount || 0) > 0)
.sort((a, b) => (b.songCount || 0) - (a.songCount || 0)); .sort((a, b) => (b.songCount || 0) - (a.songCount || 0));
el.innerHTML = ''; _renderGenres();
genres.forEach(g => { if (_allGenres.length > GENRE_LIMIT && more) more.style.display = '';
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);
});
} catch { el.style.display = 'none'; } } 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 = `
<div class="genre-card-bar" style="background:${color}"></div>
<div class="genre-card-name">${esc(g.value)}</div>
<button class="genre-card-play" title="Shuffle spielen">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>`;
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) { async function playGenre(genre) {
try { try {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`); const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
@@ -187,6 +222,37 @@ async function playGenre(genre) {
} catch {} } 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 = '<div class="spinner"></div>';
let songs = [];
try {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=500`);
songs = r.songsByGenre?.song || [];
} catch { list.innerHTML = '<p style="color:var(--danger)">Fehler beim Laden</p>'; 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) { export async function loadAlbumRow(id, method, extra) {
const c = document.getElementById(id); const c = document.getElementById(id);
if(!c) return; if(!c) return;
+11 -4
View File
@@ -133,10 +133,17 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
.home-hero-play:hover{transform:scale(1.1)} .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}} @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 GRID ── */
.genre-chips{display:flex;flex-wrap:wrap;gap:7px;padding:2px 0 4px} .genre-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px;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-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-chip:hover{background:var(--accent);color:#fff;border-color:var(--accent)} .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 ── */
.section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:24px} .section-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:24px}