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:
1 parent
d3a1bf6a82
commit
f79817410b
5 files changed
+115
-19
No files matched your search
+2
-2
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+77
-11
@@ -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 = `
|
||||
<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) {
|
||||
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 = '<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) {
|
||||
const c = document.getElementById(id);
|
||||
if(!c) return;
|
||||
|
||||
Reference in new issue
Block a user