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:
1 parent
65fa950043
commit
a2211a62d2
6 files changed
+25
-67
No files matched your search
+9
-10
@@ -72,16 +72,15 @@ 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.
|
||||
- **Zuletzt Gehörtes überlebt den Neustart (Web + Android)** — Android sicherte bisher nur den
|
||||
letzten Song, ohne Warteschlange: die Now-Playing-Leiste blieb nach dem Öffnen **leer, bis man
|
||||
einmal auf Play drückte**. Jetzt merken sich beide Plattformen Warteschlange und aktuellen Titel
|
||||
und stellen sie beim Start wieder her — Android **vorbereitet, aber nicht abgespielt**, damit
|
||||
einen die App beim Öffnen nicht überfällt; läuft schon etwas, passiert nichts. Der Titel beginnt
|
||||
von vorn: die Sekunde im Titel wird bewusst **nicht** gespeichert, das spart dauerndes Schreiben
|
||||
während der Wiedergabe. Gesichert werden nur Song-Ids, weil Titel und Cover sich durch
|
||||
Metadaten-Bearbeitung ändern können; verschwundene Titel werden ü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
|
||||
|
||||
@@ -169,8 +169,7 @@ 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 saveQueueState(ids: List<String>, index: Int) = prefs.saveQueueState(ids, index)
|
||||
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)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -32,10 +31,11 @@ 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.
|
||||
// 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_INDEX = intPreferencesKey("queue_index")
|
||||
val KEY_QUEUE_POSITION = longPreferencesKey("queue_position_ms")
|
||||
}
|
||||
|
||||
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:
|
||||
* 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 ->
|
||||
prefs[KEY_QUEUE_IDS] = ids.joinToString(",")
|
||||
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
|
||||
prefs[KEY_QUEUE_POSITION] = positionMs.coerceAtLeast(0)
|
||||
prefs[KEY_QUEUE_IDS] = ids.joinToString(",")
|
||||
prefs[KEY_QUEUE_INDEX] = index.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? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
@@ -164,8 +163,7 @@ class PrefsStore(private val context: Context) {
|
||||
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,
|
||||
index = (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, ids.lastIndex),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* vor — die haben ihren eigenen Fortschritt auf dem Server.
|
||||
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
|
||||
* Titel beginnt von vorn. 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) } }
|
||||
scope.launch { runCatching { repo.saveQueueState(ids, index) } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +89,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
|
||||
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.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
|
||||
ctrl.prepare()
|
||||
syncState()
|
||||
}
|
||||
|
||||
+1
-6
@@ -13,7 +13,7 @@ import {
|
||||
toggleShuffle, toggleRepeat, toggleQueue, setVolume, seekTo,
|
||||
initProgressScrub, updateProgress, updatePreviewProgress, stopPreview,
|
||||
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
|
||||
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, persistPosition, setPlayIcon,
|
||||
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
|
||||
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
|
||||
} from './player/player.js';
|
||||
import {
|
||||
@@ -158,9 +158,6 @@ 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;
|
||||
@@ -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));
|
||||
document.addEventListener('keydown', e => {
|
||||
if(e.key !== 'Backspace') return;
|
||||
|
||||
+1
-34
@@ -1364,25 +1364,6 @@ 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');
|
||||
@@ -1407,20 +1388,6 @@ 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 {}
|
||||
}
|
||||
Reference in new issue
Block a user