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

+21 -1
View File
@@ -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
@@ -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
+6 -1
View File
@@ -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;
+34
View File
@@ -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 {}
}
+33
View File
@@ -135,6 +135,7 @@ function _renderCompare(body, d) {
body.innerHTML = `<div class="cmp-body">
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
<div class="cmp-wide">${_totals(d)}</div>
<div>${_streitpunkte(d)}</div>
<div>${_sharedArtists(d.sharedArtists)}</div>
<div>${_sharedTracks(d.sharedTracks)}</div>
<div class="cmp-wide">${_uniqueCols(d)}</div>
@@ -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 `<div ${nav} style="padding:7px 0">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:4px">
<span class="cmp-num">${a.playsA}</span>
<span style="font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.name)}</span>
<span class="cmp-num">${a.playsB}</span>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--surface2)">
<div style="width:${anteilA}%;background:var(--accent)"></div>
<div style="width:${100 - anteilA}%;background:var(--accent);opacity:.35"></div>
</div>
</div>`;
}).join('');
return `${_h('Streitpunkte')}<div>${rows}</div>`;
}
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);