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:
1 parent
377a6954e3
commit
d3a1bf6a82
5 files changed
+133
-11
No files matched your search
@@ -138,6 +138,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
|
|||||||
mediaController = try { future.get() } catch (_: Exception) { null }
|
mediaController = try { future.get() } catch (_: Exception) { null }
|
||||||
if (mediaController != null) {
|
if (mediaController != null) {
|
||||||
lastOnReadyMs = System.currentTimeMillis()
|
lastOnReadyMs = System.currentTimeMillis()
|
||||||
|
updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady
|
||||||
callJs("onReady")
|
callJs("onReady")
|
||||||
}
|
}
|
||||||
}, ContextCompat.getMainExecutor(this))
|
}, ContextCompat.getMainExecutor(this))
|
||||||
@@ -246,6 +247,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener {
|
|||||||
put("isPlaying", ctrl.isPlaying)
|
put("isPlaying", ctrl.isPlaying)
|
||||||
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
|
put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING)
|
||||||
put("currentIndex", ctrl.currentMediaItemIndex)
|
put("currentIndex", ctrl.currentMediaItemIndex)
|
||||||
|
put("currentId", ctrl.currentMediaItem?.mediaId ?: "")
|
||||||
put("positionMs", ctrl.currentPosition)
|
put("positionMs", ctrl.currentPosition)
|
||||||
put("queueLength", ctrl.mediaItemCount)
|
put("queueLength", ctrl.mediaItemCount)
|
||||||
}.toString()
|
}.toString()
|
||||||
|
|||||||
+4
-1
@@ -133,7 +133,10 @@
|
|||||||
<!-- HOME -->
|
<!-- HOME -->
|
||||||
<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 class="section-header" style="margin-top:8px"><span class="section-title">Zuletzt gespielt</span></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 id="home-genres" class="genre-chips"></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>
|
||||||
<div class="scroll-row" id="home-random"></div>
|
<div class="scroll-row" id="home-random"></div>
|
||||||
|
|||||||
+34
-10
@@ -81,19 +81,33 @@ window.NSPlayer = {
|
|||||||
const state = raw ? (JSON.parse(raw) || {}) : {};
|
const state = raw ? (JSON.parse(raw) || {}) : {};
|
||||||
const nativeLen = state.queueLength || 0;
|
const nativeLen = state.queueLength || 0;
|
||||||
const nativePlaying = state.isPlaying || false;
|
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) {
|
if (nativeLen > 0 && nativePlaying) {
|
||||||
// Native is actively playing — never rebuild the queue, it would restart the song.
|
if (inSync) {
|
||||||
// Just re-sync JS state and UI from whatever native reports.
|
// Native is actively playing and we have the right song — re-sync.
|
||||||
S.qIdx = state.currentIndex ?? S.qIdx;
|
S.qIdx = nativeIdx;
|
||||||
_nativePlaying = nativePlaying;
|
_nativePlaying = true;
|
||||||
_nativePos = state.positionMs || 0;
|
_nativePos = state.positionMs || 0;
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
|
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
|
||||||
setPlayIcon(_nativePlaying);
|
setPlayIcon(true);
|
||||||
renderQueue();
|
renderQueue();
|
||||||
} else if (nativeLen > 0 && nativeLen === S.queue.length) {
|
} else {
|
||||||
// Paused but queue matches — re-sync UI state only.
|
// Native is playing a song we don't have (e.g. radio advanced while backgrounded).
|
||||||
S.qIdx = state.currentIndex ?? S.qIdx;
|
// 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;
|
_nativePlaying = false;
|
||||||
_nativePos = state.positionMs || 0;
|
_nativePos = state.positionMs || 0;
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
@@ -140,11 +154,15 @@ window.NSPlayer = {
|
|||||||
try {
|
try {
|
||||||
const live = JSON.parse(window.NSBridge.getState() || '{}');
|
const live = JSON.parse(window.NSBridge.getState() || '{}');
|
||||||
const liveIdx = live.currentIndex;
|
const liveIdx = live.currentIndex;
|
||||||
|
const liveId = live.currentId || '';
|
||||||
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
|
if (typeof liveIdx === 'number' && liveIdx !== S.qIdx &&
|
||||||
liveIdx >= 0 && liveIdx < S.queue.length) {
|
liveIdx >= 0 && liveIdx < S.queue.length) {
|
||||||
|
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;
|
S.qIdx = liveIdx;
|
||||||
const s = S.queue[liveIdx];
|
if (expected) { updatePlayerUI(expected, true); onNpSongChange(expected.id); }
|
||||||
if (s) { updatePlayerUI(s, true); onNpSongChange(s.id); }
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -160,11 +178,17 @@ window.NSPlayer = {
|
|||||||
// Reconcile with the live native state so stale out-of-order callbacks
|
// Reconcile with the live native state so stale out-of-order callbacks
|
||||||
// (e.g. from rapid skips) don't overwrite the already-correct index.
|
// (e.g. from rapid skips) don't overwrite the already-correct index.
|
||||||
let idx = p.index;
|
let idx = p.index;
|
||||||
|
let liveId = '';
|
||||||
try {
|
try {
|
||||||
const live = JSON.parse(window.NSBridge.getState() || '{}');
|
const live = JSON.parse(window.NSBridge.getState() || '{}');
|
||||||
if (typeof live.currentIndex === 'number') idx = live.currentIndex;
|
if (typeof live.currentIndex === 'number') idx = live.currentIndex;
|
||||||
|
liveId = live.currentId || '';
|
||||||
} catch {}
|
} catch {}
|
||||||
if (idx < 0 || idx >= S.queue.length) return;
|
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);
|
const indexChanged = (idx !== S.qIdx);
|
||||||
S.qIdx = idx;
|
S.qIdx = idx;
|
||||||
const s = S.queue[S.qIdx];
|
const s = S.queue[S.qIdx];
|
||||||
|
|||||||
@@ -109,12 +109,84 @@ export async function loadHomeData() {
|
|||||||
const el = document.getElementById('home-greeting');
|
const el = document.getElementById('home-greeting');
|
||||||
if (el) el.textContent = `${greeting}${name} 👋`;
|
if (el) el.textContent = `${greeting}${name} 👋`;
|
||||||
|
|
||||||
|
loadHeroCard();
|
||||||
|
loadGenreSection();
|
||||||
loadAlbumRow('home-recent', 'getAlbumList2', '&type=recent&size=20');
|
loadAlbumRow('home-recent', 'getAlbumList2', '&type=recent&size=20');
|
||||||
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
|
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
|
||||||
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20');
|
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20');
|
||||||
loadAlbumRow('home-frequent', 'getAlbumList2', '&type=frequent&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) {
|
export async function loadAlbumRow(id, method, extra) {
|
||||||
const c = document.getElementById(id);
|
const c = document.getElementById(id);
|
||||||
if(!c) return;
|
if(!c) return;
|
||||||
|
|||||||
+21
@@ -117,6 +117,27 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
|
|||||||
/* ── HOME GREETING ── */
|
/* ── HOME GREETING ── */
|
||||||
.home-greeting{font-size:22px;font-weight:700;color:var(--text);padding:18px 0 6px;letter-spacing:-.3px}
|
.home-greeting{font-size:22px;font-weight:700;color:var(--text);padding:18px 0 6px;letter-spacing:-.3px}
|
||||||
|
|
||||||
|
/* ── HOME HERO ── */
|
||||||
|
.home-hero{position:relative;height:170px;border-radius:14px;overflow:hidden;margin-top:8px;background:var(--surface2);display:none}
|
||||||
|
.home-hero.loaded{display:block}
|
||||||
|
.home-hero-bg{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:brightness(.45) blur(8px);transform:scale(1.08)}
|
||||||
|
.home-hero-overlay{position:absolute;inset:0;background:linear-gradient(to right,rgba(7,7,15,.88) 0%,rgba(7,7,15,.3) 100%);display:flex;align-items:center;padding:16px 20px;gap:16px}
|
||||||
|
.home-hero-cover{width:100px;height:100px;border-radius:9px;object-fit:cover;flex-shrink:0;box-shadow:0 6px 20px rgba(0,0,0,.55);cursor:pointer;transition:transform .2s}
|
||||||
|
.home-hero-cover:hover{transform:scale(1.04)}
|
||||||
|
.home-hero-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||||
|
.home-hero-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--accent)}
|
||||||
|
.home-hero-title{font-size:18px;font-weight:800;letter-spacing:-.4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.home-hero-artist{font-size:13px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
|
||||||
|
.home-hero-artist:hover{color:var(--text)}
|
||||||
|
.home-hero-play{width:42px;height:42px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:transform .15s,opacity .15s;margin-left:auto}
|
||||||
|
.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)}
|
||||||
|
|
||||||
/* ── 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}
|
||||||
.section-title{font-size:16px;font-weight:700}
|
.section-title{font-size:16px;font-weight:700}
|
||||||
|
|||||||
Reference in new issue
Block a user