refactor(web+android): nur Warteschlange und Titel sichern, keine Sekunden

Das Mitschreiben der Abspielposition war zu viel des Guten: es bedeutete
staendiges Schreiben waehrend der Wiedergabe fuer einen Gewinn, den man kaum
bemerkt. Gesichert werden jetzt nur Warteschlange und aktueller Titel, der dann
von vorn beginnt.

Der eigentliche Gewinn bleibt und war der Ausloeser: unter Android war die
Now-Playing-Leiste nach dem Oeffnen leer, bis man einmal auf Play drueckte —
gesichert war nur der letzte Song, nicht die Warteschlange. Das Wiederherstellen
fuellt den Zustand jetzt ueber syncState(), also ohne Abspielen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-06 23:25:06 +02:00
1 parent 65fa950043
commit a2211a62d2
6 files changed
+22 -64

No files matched your search

+9 -10
View File
@@ -72,16 +72,15 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
## [2.4] — 2026-09-06 ## [2.4] — 2026-09-06
### Added ### Added
- **Wiedergabe fortsetzen, wo du aufgehört hast (Web + Android)** — bisher überlebte im Web nur der - **Zuletzt Gehörtes überlebt den Neustart (Web + Android)** — Android sicherte bisher nur den
zuletzt gespielte Titel und die Warteschlange, aber **nicht die Position im Titel**; Android letzten Song, ohne Warteschlange: die Now-Playing-Leiste blieb nach dem Öffnen **leer, bis man
sicherte allein den letzten Song, ohne Warteschlange. Jetzt merken sich beide Warteschlange, einmal auf Play drückte**. Jetzt merken sich beide Plattformen Warteschlange und aktuellen Titel
Listenposition und Sekunde im Titel. Web schreibt gedrosselt beim Abspielen sowie sicher beim und stellen sie beim Start wieder her — Android **vorbereitet, aber nicht abgespielt**, damit
Pausieren, Wegwischen und Wechseln des Tabs (`pagehide`/`visibilitychange` statt `beforeunload`, einen die App beim Öffnen nicht überfällt; läuft schon etwas, passiert nichts. Der Titel beginnt
das am Handy unzuverlässig ist). Android sichert bei jedem Titelwechsel und beim Pausieren und von vorn: die Sekunde im Titel wird bewusst **nicht** gespeichert, das spart dauerndes Schreiben
stellt die Warteschlange nach dem Verbinden wieder her — **vorbereitet, aber nicht abgespielt**: während der Wiedergabe. Gesichert werden nur Song-Ids, weil Titel und Cover sich durch
ein Druck auf Play macht weiter. Lag die Position im letzten Rest des Titels, beginnt er von vorn, Metadaten-Bearbeitung ändern können; verschwundene Titel werden übersprungen.
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 - **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 **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 zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner
@@ -169,8 +169,7 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId) suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveQueueState(ids: List<String>, index: Int, positionMs: Long) = suspend fun saveQueueState(ids: List<String>, index: Int) = prefs.saveQueueState(ids, index)
prefs.saveQueueState(ids, index, positionMs)
suspend fun readQueueState() = prefs.readQueueState() suspend fun readQueueState() = prefs.readQueueState()
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) = suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt) prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,10 +31,11 @@ class PrefsStore(private val context: Context) {
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title") val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist") val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover") val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
// Warteschlange fortsetzen: Song-Ids, Position in der Liste und Position im Titel. // Warteschlange fortsetzen: Song-Ids und Position in der Liste. Bewusst OHNE Sekunde im
// Titel — der zuletzt gehoerte Titel beginnt von vorn, das genuegt und spart staendiges
// Schreiben waehrend der Wiedergabe.
val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids") val KEY_QUEUE_IDS = stringPreferencesKey("queue_ids")
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index") 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) { data class Credentials(val serverUrl: String, val username: String, val password: String) {
@@ -148,15 +148,14 @@ class PrefsStore(private val context: Context) {
* Sichert die Warteschlange als Id-Liste samt Position. Nur Ids, keine vollstaendigen Songs: * 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. * Titel und Cover koennen sich aendern (Metadaten-Bearbeitung), die Id ist der stabile Bezug.
*/ */
suspend fun saveQueueState(ids: List<String>, index: Int, positionMs: Long) { suspend fun saveQueueState(ids: List<String>, index: Int) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs[KEY_QUEUE_IDS] = ids.joinToString(",") prefs[KEY_QUEUE_IDS] = ids.joinToString(",")
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0) 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) data class QueueState(val ids: List<String>, val index: Int)
suspend fun readQueueState(): QueueState? { suspend fun readQueueState(): QueueState? {
val prefs = context.dataStore.data.first() val prefs = context.dataStore.data.first()
@@ -165,7 +164,6 @@ class PrefsStore(private val context: Context) {
return QueueState( return QueueState(
ids = ids, ids = ids,
index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex), index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex),
positionMs = prefs[KEY_QUEUE_POSITION] ?: 0L,
) )
} }
@@ -62,16 +62,16 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
} }
/** /**
* Haelt Warteschlange, Listenposition und Position im Titel fest. ABS-Hoerbuecher bleiben aussen * Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
* vor — die haben ihren eigenen Fortschritt auf dem Server. * Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
* Fortschritt auf dem Server.
*/ */
private fun persistQueueState() { private fun persistQueueState() {
val ctrl = controller ?: return val ctrl = controller ?: return
val ids = queue.map { it.id } val ids = queue.map { it.id }
if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return if (ids.isEmpty() || ids.any { it.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex val index = ctrl.currentMediaItemIndex
val pos = ctrl.currentPosition scope.launch { runCatching { repo.saveQueueState(ids, index) } }
scope.launch { runCatching { repo.saveQueueState(ids, index, pos) } }
} }
/** /**
@@ -89,7 +89,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
if (songs.isEmpty()) return@launch if (songs.isEmpty()) return@launch
val index = zustand.index.coerceIn(0, songs.lastIndex) val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs queue = songs
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, zustand.positionMs) ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare() ctrl.prepare()
syncState() syncState()
} }
+1 -6
View File
@@ -13,7 +13,7 @@ import {
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo, toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview, initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview, toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, persistPosition, setPlayIcon, toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts, npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
} from './player/player.js'; } from './player/player.js';
import { import {
@@ -158,9 +158,6 @@ window.addEventListener('load', () => {
let _errRetries = 0, _errTimer = null; let _errRetries = 0, _errTimer = null;
function bindAudioEl(el) { function bindAudioEl(el) {
el.addEventListener('timeupdate', updateProgress); 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('ended', onEnded);
el.addEventListener('play', () => { el.addEventListener('play', () => {
S._trackChanging = false; S._trackChanging = false;
@@ -318,8 +315,6 @@ window.addEventListener('load', () => {
} }
}); });
window.addEventListener('pagehide', () => persistPosition(true));
document.addEventListener('visibilitychange', () => { if (document.hidden) persistPosition(true); });
window.addEventListener('popstate', (e) => _navOnPopState(e.state)); window.addEventListener('popstate', (e) => _navOnPopState(e.state));
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if(e.key !== 'Backspace') return; if(e.key !== 'Backspace') return;
+1 -34
View File
@@ -1364,25 +1364,6 @@ export function reconcileNativeState() {
} catch {} } 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() { export function _restoreLastPlayed() {
try { try {
const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null'); const saved = JSON.parse(localStorage.getItem('ns_last_played') || 'null');
@@ -1407,20 +1388,6 @@ export function _restoreLastPlayed() {
// Web path: prime the audio element so play button works immediately. // Web path: prime the audio element so play button works immediately.
S.audio.src = streamUrl(s.id); S.audio.src = streamUrl(s.id);
S.audio.load(); 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 {} } catch {}
} }