feat: home hero+genres, fix Android queue desync via song ID verification

Home view:
- Add hero card (random pick from recent+frequent combined pool)
- Add genre browser chips (sorted by song count, tap = shuffle-play)

Android sync fix:
- Add currentId to getState() snapshot so JS can verify song identity
- onReady: reset stale localStorage queue when native song ID mismatches
- onTrackChanged: reject stale old-queue callbacks by ID verification
- onPlaybackStateChanged safety net: ID-guard prevents wrong-queue index corruption
- connectToService: call updateStateSnapshot() before onReady so getState() is never empty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-21 09:06:47 +02:00
1 parent 377a6954e3
commit d3a1bf6a82
5 files changed
+142 -20

No files matched your search

+43 -19
View File
@@ -79,23 +79,37 @@ window.NSPlayer = {
try {
const raw = window.NSBridge.getState();
const state = raw ? (JSON.parse(raw) || {}) : {};
const nativeLen = state.queueLength || 0;
const nativePlaying = state.isPlaying || false;
const nativeLen = state.queueLength || 0;
const nativePlaying = state.isPlaying || false;
const nativeIdx = typeof state.currentIndex === 'number' ? state.currentIndex : S.qIdx;
const nativeId = state.currentId || '';
// Helper: true if native's current song matches JS queue at nativeIdx
const inSync = nativeIdx >= 0 && nativeIdx < S.queue.length &&
(!nativeId || !S.queue[nativeIdx]?.id ||
String(nativeId) === String(S.queue[nativeIdx].id));
if (nativeLen > 0 && nativePlaying) {
// Native is actively playing — never rebuild the queue, it would restart the song.
// Just re-sync JS state and UI from whatever native reports.
S.qIdx = state.currentIndex ?? S.qIdx;
_nativePlaying = nativePlaying;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(_nativePlaying);
renderQueue();
} else if (nativeLen > 0 && nativeLen === S.queue.length) {
// Paused but queue matches — re-sync UI state only.
S.qIdx = state.currentIndex ?? S.qIdx;
if (inSync) {
// Native is actively playing and we have the right song — re-sync.
S.qIdx = nativeIdx;
_nativePlaying = true;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(true);
renderQueue();
} else {
// Native is playing a song we don't have (e.g. radio advanced while backgrounded).
// Reset the stale JS queue to avoid showing the wrong song — native keeps playing.
S.queue = [];
S.qIdx = 0;
}
} else if (nativeLen > 0 && inSync) {
// Paused but we can confirm the current song — re-sync UI state only.
S.qIdx = nativeIdx;
_nativePlaying = false;
_nativePos = state.positionMs || 0;
_nativePos = state.positionMs || 0;
const s = S.queue[S.qIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
setPlayIcon(false);
@@ -138,13 +152,17 @@ window.NSPlayer = {
// Catches any edge cases where onTrackChanged was missed or queue drifted.
if (state.isPlaying) {
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
const live = JSON.parse(window.NSBridge.getState() || '{}');
const liveIdx = live.currentIndex;
const liveId = live.currentId || '';
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
liveIdx >= 0 && liveIdx < S.queue.length) {
S.qIdx = liveIdx;
const s = S.queue[liveIdx];
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
const expected = S.queue[liveIdx];
// Only update if song ID confirms native and JS queues are in sync.
if (!liveId || !expected?.id || String(liveId) === String(expected.id)) {
S.qIdx = liveIdx;
if (expected) { updatePlayerUI(expected, true); onNpSongChange(expected.id); }
}
}
} catch {}
}
@@ -160,11 +178,17 @@ window.NSPlayer = {
// Reconcile with the live native state so stale out-of-order callbacks
// (e.g. from rapid skips) don't overwrite the already-correct index.
let idx = p.index;
let liveId = '';
try {
const live = JSON.parse(window.NSBridge.getState() || '{}');
if (typeof live.currentIndex === 'number') idx = live.currentIndex;
liveId = live.currentId || '';
} catch {}
if (idx < 0 || idx >= S.queue.length) return;
// Reject stale callbacks from the old queue: if the song ID at this index
// 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;
const indexChanged = (idx !== S.qIdx);
S.qIdx = idx;
const s = S.queue[S.qIdx];
+72
View File
@@ -109,12 +109,84 @@ export async function loadHomeData() {
const el = document.getElementById('home-greeting');
if (el) el.textContent = `${greeting}${name} 👋`;
loadHeroCard();
loadGenreSection();
loadAlbumRow('home-recent', 'getAlbumList2', '&type=recent&size=20');
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20');
loadAlbumRow('home-frequent', 'getAlbumList2', '&type=frequent&size=20');
}
async function loadHeroCard() {
const el = document.getElementById('home-hero');
if (!el) return;
try {
// Combine recent + frequent into a pool and pick one randomly.
// This gives new users (no play history) recent albums, and returning users a mix.
const [freqRes, recRes] = await Promise.allSettled([
api('getAlbumList2', '&type=frequent&size=5'),
api('getAlbumList2', '&type=recent&size=5'),
]);
const freqList = freqRes.status === 'fulfilled' ? (freqRes.value.albumList2?.album || []) : [];
const recList = recRes.status === 'fulfilled' ? (recRes.value.albumList2?.album || []) : [];
// Deduplicate by id, prefer frequent (weighted taste signal), pad with recent
const seen = new Set();
const pool = [...freqList, ...recList].filter(a => a.id && !seen.has(a.id) && seen.add(a.id));
const album = pool.length ? pool[Math.floor(Math.random() * Math.min(5, pool.length))] : null;
if (!album) return;
const bgUrl = coverUrl(album.coverArt, 1024);
const thumbUrl = coverUrl(album.coverArt, 512);
el.innerHTML = `
${bgUrl ? `<img class="home-hero-bg" src="${bgUrl}" alt="">` : ''}
<div class="home-hero-overlay">
${thumbUrl ? `<img class="home-hero-cover" src="${thumbUrl}" alt="${esc(album.name)}">` : ''}
<div class="home-hero-info">
<div class="home-hero-label">Meistgespielt</div>
<div class="home-hero-title">${esc(album.name)}</div>
<div class="home-hero-artist">${esc(album.artist || '')}</div>
</div>
<button class="home-hero-play" title="Abspielen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
</div>`;
el.querySelector('.home-hero-cover')?.addEventListener('click', () => navigate('album-detail', album));
el.querySelector('.home-hero-artist').onclick = () => navigate('album-detail', album);
el.querySelector('.home-hero-play').onclick = () => quickPlayAlbum(album);
el.classList.add('loaded');
} catch { /* hero is optional — silent fail */ }
}
async function loadGenreSection() {
const el = document.getElementById('home-genres');
if (!el) return;
try {
const r = await api('getGenres');
const genres = (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);
});
} catch { el.style.display = 'none'; }
}
async function playGenre(genre) {
try {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
const songs = r.songsByGenre?.song || [];
if (!songs.length) return;
const shuffled = [...songs].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
navigate('now-playing');
} catch {}
}
export async function loadAlbumRow(id, method, extra) {
const c = document.getElementById(id);
if(!c) return;