feat(web+android): Wiedergabe fortsetzen und Streitpunkte im Duell (2.4)

Wiedergabe fortsetzen:
Im Web ueberlebten Warteschlange und letzter Titel bereits, die POSITION im
Titel aber nicht — beim Neuladen begann der Song von vorn. Android sicherte nur
den letzten Song ohne Warteschlange. Beide merken sich jetzt Warteschlange,
Listenposition und Sekunde.

Web schreibt gedrosselt (alle 5 s) waehrend der Wiedergabe und sicher beim
Pausieren, Wegwischen und Tabwechsel — pagehide/visibilitychange statt
beforeunload, das am Handy unzuverlaessig feuert. Android sichert bei jedem
Titelwechsel und beim Pausieren und stellt nach dem Verbinden wieder her:
vorbereitet, aber NICHT abgespielt, wer die App oeffnet will nicht ueberfallen
werden. Laeuft schon etwas (Android Auto), passiert nichts.

Zwei Feinheiten: lag die Position im letzten Rest des Titels, faengt er von vorn
an, statt zwei Sekunden Abspann zu spielen; und gespeichert werden nur Song-Ids,
weil Titel und Cover sich durch Metadaten-Bearbeitung aendern koennen —
verschwundene Titel werden beim Wiederherstellen uebersprungen.

Streitpunkte im Duell:
Gemeinsame Kuenstler nach groesstem Ungleichgewicht statt nach Summe, mit
Kraefteverhaeltnis-Balken. Mindestens vier gemeinsame Plays und 50 % Unwucht,
sonst gilt ein einzelner Play gegen null schon als Streit. Web und Android mit
identischen Schwellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 23:20:40 +02:00
1 parent 92c3d96936
commit 65fa950043
8 files changed
+217 -2

No files matched your search

@@ -169,6 +169,9 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveQueueState(ids: List<String>, 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)
@@ -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<String>, 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<String>, 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() }
}
@@ -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<Song>, 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)
}
@@ -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<Triple<SharedArtist, Int, Float>> { 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