diff --git a/CHANGELOG.md b/CHANGELOG.md index a90aaa9..4c86367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Playlist cover upload | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ | +| Wiedergabe fortsetzen | ✅ | ✅ | | Teilen per Link | ✅ | ✅ | | Playlist aus Import anlegen | ✅ | ✅ | | Song von Festplatte löschen | ✅ | ✅ | @@ -68,6 +69,26 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an --- +## [2.4] — 2026-09-06 + +### Added +- **Wiedergabe fortsetzen, wo du aufgehört hast (Web + Android)** — bisher überlebte im Web nur der + zuletzt gespielte Titel und die Warteschlange, aber **nicht die Position im Titel**; Android + sicherte allein den letzten Song, ohne Warteschlange. Jetzt merken sich beide Warteschlange, + Listenposition und Sekunde im Titel. Web schreibt gedrosselt beim Abspielen sowie sicher beim + Pausieren, Wegwischen und Wechseln des Tabs (`pagehide`/`visibilitychange` statt `beforeunload`, + das am Handy unzuverlässig ist). Android sichert bei jedem Titelwechsel und beim Pausieren und + stellt die Warteschlange nach dem Verbinden wieder her — **vorbereitet, aber nicht abgespielt**: + ein Druck auf Play macht weiter. Lag die Position im letzten Rest des Titels, beginnt er von vorn, + statt zwei Sekunden Abspann zu spielen. Gespeichert werden nur Song-Ids; verschwundene Titel + werden beim Wiederherstellen übersprungen. +- **Streitpunkte im Musik-Duell (Web + Android)** — gemeinsame Künstler, sortiert nach dem größten + **Ungleichgewicht** statt nach der Summe: wo einer 120 Plays hat und der andere 3. Ein Balken + zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner + Play gegen null nicht als Streit gilt. Beide Plattformen nutzen dieselben Schwellen. + +--- + ## [2.3] — 2026-09-04 ### Fixed @@ -739,7 +760,6 @@ Polish release: Android navigation and offline resilience, download accuracy for - **Android** — Romaji / Japanese sort support - **Web + Android** — Backend settings UI rework (move, reorganize) - **Web + Android** — yt-dlp backend update / maintenance -- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded) - **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web ### Known Bugs diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 18b3bcb..57ea095 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -169,6 +169,9 @@ class MusicRepository(private val context: Context) { suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId) + suspend fun saveQueueState(ids: List, index: Int, positionMs: Long) = + prefs.saveQueueState(ids, index, positionMs) + suspend fun readQueueState() = prefs.readQueueState() suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) = prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 4fd5d91..26ca013 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -3,9 +3,12 @@ package com.novasonic.app.data import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map private val Context.dataStore by preferencesDataStore("novasonic_prefs") @@ -29,6 +32,10 @@ class PrefsStore(private val context: Context) { val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover") + // Warteschlange fortsetzen: Song-Ids, Position in der Liste und Position im Titel. + val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids") + val KEY_QUEUE_INDEX = intPreferencesKey("queue_index") + val KEY_QUEUE_POSITION = longPreferencesKey("queue_position_ms") } data class Credentials(val serverUrl: String, val username: String, val password: String) { @@ -137,6 +144,31 @@ class PrefsStore(private val context: Context) { } } + /** + * Sichert die Warteschlange als Id-Liste samt Position. Nur Ids, keine vollstaendigen Songs: + * Titel und Cover koennen sich aendern (Metadaten-Bearbeitung), die Id ist der stabile Bezug. + */ + suspend fun saveQueueState(ids: List, index: Int, positionMs: Long) { + context.dataStore.edit { prefs -> + prefs[KEY_QUEUE_IDS] = ids.joinToString(",") + prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0) + prefs[KEY_QUEUE_POSITION] = positionMs.coerceAtLeast(0) + } + } + + data class QueueState(val ids: List, val index: Int, val positionMs: Long) + + suspend fun readQueueState(): QueueState? { + val prefs = context.dataStore.data.first() + val ids = prefs[KEY_QUEUE_IDS]?.split(',')?.filter { it.isNotBlank() }.orEmpty() + if (ids.isEmpty()) return null + return QueueState( + ids = ids, + index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex), + positionMs = prefs[KEY_QUEUE_POSITION] ?: 0L, + ) + } + suspend fun clear() { context.dataStore.edit { it.clear() } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt index 161c78a..f433b26 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt @@ -56,10 +56,45 @@ class PlayerController(private val context: Context, private val repo: MusicRepo controller = runCatching { future.get() }.getOrNull() controller?.addListener(PlayerListener()) syncState() + restoreQueue() }, context.mainExecutor) } } + /** + * Haelt Warteschlange, Listenposition und Position im Titel fest. ABS-Hoerbuecher bleiben aussen + * vor — die haben ihren eigenen Fortschritt auf dem Server. + */ + private fun persistQueueState() { + val ctrl = controller ?: return + val ids = queue.map { it.id } + if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return + val index = ctrl.currentMediaItemIndex + val pos = ctrl.currentPosition + scope.launch { runCatching { repo.saveQueueState(ids, index, pos) } } + } + + /** + * Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt. + * Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter, + * wo es aufgehoert hat. Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts. + */ + fun restoreQueue() { + val ctrl = controller ?: return + if (ctrl.mediaItemCount > 0) return + scope.launch { + val zustand = runCatching { repo.readQueueState() }.getOrNull() ?: return@launch + // Songs einzeln nachladen; verschwundene (geloescht, umbenannt) werden uebersprungen. + val songs = zustand.ids.mapNotNull { runCatching { repo.getSong(it) }.getOrNull() } + if (songs.isEmpty()) return@launch + val index = zustand.index.coerceIn(0, songs.lastIndex) + queue = songs + ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, zustand.positionMs) + ctrl.prepare() + syncState() + } + } + // ── Playback commands ───────────────────────────────────────────────────── fun playSongs(songs: List, startIndex: Int = 0) { @@ -317,6 +352,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo override fun onIsPlayingChanged(isPlaying: Boolean) { if (isPlaying) startTicker() else stopTicker() syncState() + // Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen. + if (!isPlaying) persistQueueState() } override fun onPlaybackStateChanged(state: Int) = syncState() @@ -336,6 +373,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0) scheduleScrobble(song, dur) scope.launch { runCatching { repo.saveLastSong(song) } } + persistQueueState() prefetchLyrics(song) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt index 49f602c..05e82b5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/CompareScreen.kt @@ -173,6 +173,8 @@ private fun CompareContent( Spacer(Modifier.height(22.dp)) } + Streitpunkte(d, onArtistClick) + SectionHeader("Nur bei…") Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) { UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f)) @@ -181,6 +183,54 @@ private fun CompareContent( } } +@Composable +private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) { + // Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame + // Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht + // als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen. + val items = d.sharedArtists + .map { a -> + val summe = a.playsA + a.playsB + Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f) + } + .filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f } + .sortedWith(compareByDescending> { it.third }.thenByDescending { it.second }) + .take(8) + + SectionHeader("Streitpunkte") + if (items.isEmpty()) { + Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.", + fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2) + return + } + items.forEach { (a, summe, _) -> + val anteilA = a.playsA / summe.toFloat() + Column( + Modifier.fillMaxWidth() + .clickable { a.artistId?.let(onArtistClick) } + .padding(vertical = 6.dp) + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, + fontWeight = FontWeight.Bold) + Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface, + fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) + Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, + fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(4.dp)) + Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) { + Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent)) + Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight() + .background(NsAccent.copy(alpha = .35f))) + } + } + } + Spacer(Modifier.height(10.dp)) +} + @Composable private fun Scoreboard(d: CompareResult) { val b = d.battle diff --git a/src/main.js b/src/main.js index 300a78f..7b9cd4f 100644 --- a/src/main.js +++ b/src/main.js @@ -13,7 +13,7 @@ import { toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, initProgressScrub, updateProgress, updatePreviewProgress, stopPreview, toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, - toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon, + toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, persistPosition, setPlayIcon, npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts, } from './player/player.js'; import { @@ -158,6 +158,9 @@ window.addEventListener('load', () => { let _errRetries = 0, _errTimer = null; function bindAudioEl(el) { el.addEventListener('timeupdate', updateProgress); + // Position mitschreiben (gedrosselt), damit ein Neuladen dort weitermacht, wo du warst. + el.addEventListener('timeupdate', () => persistPosition()); + el.addEventListener('pause', () => persistPosition(true)); el.addEventListener('ended', onEnded); el.addEventListener('play', () => { S._trackChanging = false; @@ -315,6 +318,8 @@ window.addEventListener('load', () => { } }); +window.addEventListener('pagehide', () => persistPosition(true)); +document.addEventListener('visibilitychange', () => { if (document.hidden) persistPosition(true); }); window.addEventListener('popstate', (e) => _navOnPopState(e.state)); document.addEventListener('keydown', e => { if(e.key !== 'Backspace') return; diff --git a/src/player/player.js b/src/player/player.js index 69c9411..8c23718 100644 --- a/src/player/player.js +++ b/src/player/player.js @@ -1364,6 +1364,25 @@ export function reconcileNativeState() { } catch {} } +// Abspielposition mitschreiben. Der Eintrag entsteht beim Titelwechsel; hier wird nur die Position +// nachgetragen, damit ein Neuladen dort weitermacht, wo du warst — nicht am Anfang des Titels. +// Gedrosselt, weil timeupdate mehrmals pro Sekunde feuert und localStorage synchron schreibt. +let _posLastWrite = 0; +export function persistPosition(force = false) { + if (HAS_BRIDGE) return; // nativ verwaltet Media3 den Zustand + const jetzt = Date.now(); + if (!force && jetzt - _posLastWrite < 5000) return; + _posLastWrite = jetzt; + try { + const roh = localStorage.getItem('ns_last_played'); + if (!roh) return; + const daten = JSON.parse(roh); + daten.position = S.audio?.currentTime || 0; + daten.savedAt = Math.floor(jetzt / 1000); + localStorage.setItem('ns_last_played', JSON.stringify(daten)); + } catch {} +} + export function _restoreLastPlayed() { try { const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); @@ -1388,5 +1407,20 @@ export function _restoreLastPlayed() { // Web path: prime the audio element so play button works immediately. S.audio.src = streamUrl(s.id); S.audio.load(); + // Gespeicherte Position wiederherstellen, sobald die Dauer bekannt ist. Lag sie im letzten + // Rest des Titels, faengt der Titel lieber von vorn an — sonst spielt man beim Fortsetzen + // zwei Sekunden Abspann und springt weiter. + const pos = Number(saved.position) || 0; + if (pos > 1) { + const setzen = () => { + const rest = (S.audio.duration || 0) - pos; + if (S.audio.duration && rest > 5) { + try { S.audio.currentTime = pos; } catch {} + updateProgress(); + } + S.audio.removeEventListener('loadedmetadata', setzen); + }; + S.audio.addEventListener('loadedmetadata', setzen); + } } catch {} } diff --git a/src/ui/compare.js b/src/ui/compare.js index 084f44c..060b832 100644 --- a/src/ui/compare.js +++ b/src/ui/compare.js @@ -135,6 +135,7 @@ function _renderCompare(body, d) { body.innerHTML = `
${_scoreboard(d, b, urteil)}
${_totals(d)}
+
${_streitpunkte(d)}
${_sharedArtists(d.sharedArtists)}
${_sharedTracks(d.sharedTracks)}
${_uniqueCols(d)}
@@ -177,6 +178,38 @@ function _h(t) { // Shared artist row: a centered name with each side's play count on its flank + a // mirrored bar so you can see who plays them more. +// Streitpunkte: gemeinsame Künstler, bei denen die Zählerstände am weitesten auseinanderliegen. +// Umgekehrt zu "Gemeinsame Künstler", das nach Summe sortiert und damit zeigt, worin ihr euch einig +// seid. Sortiert wird nach relativem Ungleichgewicht, sonst stünden nur die meistgehörten oben. +function _streitpunkte(d) { + const items = (d.sharedArtists || []) + .map(a => { + const summe = a.playsA + a.playsB; + return { ...a, unwucht: summe ? Math.abs(a.playsA - a.playsB) / summe : 0, summe }; + }) + // Ein einzelner Play gegen null ist kein Streit, sondern Zufall — Mindestmasse verlangen. + .filter(a => a.summe >= 4 && a.unwucht >= 0.5) + .sort((x, y) => (y.unwucht - x.unwucht) || (y.summe - x.summe)) + .slice(0, 8); + if (!items.length) return `${_h('Streitpunkte')}${_info('Ihr hört gemeinsame Künstler erstaunlich ausgewogen.')}`; + const rows = items.map(a => { + const anteilA = Math.round((a.playsA / a.summe) * 100); + const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : ''; + return `
+
+ ${a.playsA} + ${esc(a.name)} + ${a.playsB} +
+
+
+
+
+
`; + }).join(''); + return `${_h('Streitpunkte')}
${rows}
`; +} + function _sharedArtists(items) { if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`; const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);