diff --git a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt index 55adaa8..144f7bc 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MainActivity.kt @@ -138,6 +138,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { mediaController = try { future.get() } catch (_: Exception) { null } if (mediaController != null) { lastOnReadyMs = System.currentTimeMillis() + updateStateSnapshot() // ensure getState() is fresh when JS calls it in onReady callJs("onReady") } }, ContextCompat.getMainExecutor(this)) @@ -246,6 +247,7 @@ class MainActivity : AppCompatActivity(), MusicService.StateListener { put("isPlaying", ctrl.isPlaying) put("isBuffering", ctrl.playbackState == Player.STATE_BUFFERING) put("currentIndex", ctrl.currentMediaItemIndex) + put("currentId", ctrl.currentMediaItem?.mediaId ?: "") put("positionMs", ctrl.currentPosition) put("queueLength", ctrl.mediaItemCount) }.toString() diff --git a/index.html b/index.html index b9b5d1e..6506c73 100644 --- a/index.html +++ b/index.html @@ -133,7 +133,10 @@
-
Zuletzt gespielt
+
+
Genres
+
+
Zuletzt gespielt
ZufälligMehr
diff --git a/src/player.js b/src/player.js index 3fdd34b..005ab96 100644 --- a/src/player.js +++ b/src/player.js @@ -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]; diff --git a/src/views.js b/src/views.js index 94dcc7a..0dc09ef 100644 --- a/src/views.js +++ b/src/views.js @@ -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 ? `` : ''} +
+ ${thumbUrl ? `${esc(album.name)}` : ''} +
+
Meistgespielt
+
${esc(album.name)}
+
${esc(album.artist || '')}
+
+ +
`; + 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; diff --git a/styles.css b/styles.css index 4d2eb13..5ee6597 100644 --- a/styles.css +++ b/styles.css @@ -117,6 +117,27 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} /* ── HOME GREETING ── */ .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-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:24px} .section-title{font-size:16px;font-weight:700}