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:
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
|
||||
|
||||
Reference in new issue
Block a user