fix(android): große Offline-Playlists ließen sich nicht abspielen

Die Warteschlange wurde aus allen Titeln einer Gruppe gebaut, ohne den
Download-Zustand zu prüfen. Die Map für lokale Dateien kennt aber nur
state = 'DONE' — für wartende oder fehlgeschlagene Titel lieferte
streamUrl() die Netz-Adresse, die offline nichts tut. Bei 100 Titeln ist
der Download durch, bei 1900 nicht; steht so ein Titel vorn, scheitert
die Wiedergabe sofort.

Play und Shuffle nehmen jetzt nur fertige Titel, ein Toast nennt die Zahl
der übersprungenen. Die Liste zeigt weiter alle, damit sichtbar bleibt,
was fehlt; Tippen auf einen unfertigen sagt es ebenfalls.

Nicht die Ursache: Media3 reicht große Listen als BundleListRetriever
durch, die 1-MB-Binder-Grenze greift dort nicht.

Dazu Backlog-Notiz: Künstler/Alben liken als Basis für "Künstler folgen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-14 22:50:27 +02:00
1 parent cb5ea26c77
commit 35e36067af
3 files changed
+59 -4

No files matched your search

+26
View File
@@ -107,6 +107,20 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
weil `main.js` die schon offene Seite bereits auf `nsIdx 0` gesetzt hatte — auch das war ein
toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet).
### Fixed
- **Große Offline-Playlists ließen sich nicht abspielen (Android)** — bei ~100 Titeln ging alles, bei
1900 nicht. Ursache war nicht die Größe an sich: die Warteschlange wurde aus **allen** Titeln der
Gruppe gebaut, ohne Rücksicht auf den Download-Zustand. Die Karte, aus der die lokale Datei kommt,
kennt dagegen nur fertige (`WHERE state = 'DONE'`) — für wartende oder fehlgeschlagene Titel
lieferte `streamUrl()` deshalb die **Netz-Adresse**, und die tut offline nichts. Bei 100 Titeln ist
der Download längst durch, bei 1900 praktisch nie; liegt so ein Titel vorn, scheitert die
Wiedergabe sofort und sieht aus, als ginge die ganze Playlist nicht. Play und Shuffle nehmen jetzt
nur fertige Titel, ein Hinweis nennt die Zahl der übersprungenen. Die Liste zeigt weiterhin alle,
damit man sieht, was noch fehlt; ein Tipp auf einen unfertigen Titel sagt das auch.
- Verworfen: die naheliegende Vermutung, Media3 stoße bei 1900 Einträgen an die 1-MB-Grenze einer
Binder-Transaktion. Tut es nicht — `MediaControllerImplBase` reicht die Liste als
`BundleListRetriever` durch, den die Session seitenweise abholt. Die Größe ist unschuldig.
### Added
- **Bei geteilten Playlists steht jetzt dabei, von wem sie sind (Web + Android)** — Navidrome
liefert `owner` in `getPlaylists`/`getPlaylist` mit, im Web wurde das Feld nie benutzt. Übersicht
@@ -868,6 +882,18 @@ Polish release: Android navigation and offline resilience, download accuracy for
- **Web + Android** — yt-dlp backend update / maintenance
- **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
### Ideen / Backlog
- **Künstler und Alben liken, daraus „folgen" bauen** — anders als Playlists sind Künstler und Alben
in der Subsonic-API wirklich markierbar. Navidrome wirft in `media_annotation.go` sogar alle drei
Parameter in eine Liste (`id`, `albumId`, `artistId`) und behandelt sie gleich, unser bestehender
Aufruf `api('star', '&id=' + id)` würde für Künstler also **ohne Änderung** funktionieren; den
Zustand liefert `getStarred2` mit Künstlern und Alben zurück. Es fehlt nur die Oberfläche: ein Herz
auf der Künstler- und Albumseite. Darauf ließe sich das länger angedachte *Künstler folgen* setzen
— neue Veröffentlichungen markierter Künstler über Deezer erkennen und melden, ohne dafür eine
eigene Liste pflegen zu müssen. Playlists gehen dafür ausdrücklich **nicht**: `getStarred`/
`getStarred2` geben nur Künstler, Alben und Songs zurück, ein in Navidromes Oberfläche geliktes
Playlist ist über die API unsichtbar.
### Known Bugs
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
@@ -31,6 +31,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
@@ -62,6 +63,20 @@ fun OfflineDetailScreen(
return
}
val songs = remember(tracks) { tracks.map { it.toSong() } }
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
val spielbar = remember(tracks) {
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
}
val ctx = androidx.compose.ui.platform.LocalContext.current
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
val fehlend = songs.size - spielbar.size
if (fehlend > 0) android.widget.Toast.makeText(
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
android.widget.Toast.LENGTH_SHORT).show()
onPlay(liste, i)
}
LazyColumn(Modifier.fillMaxSize()) {
item {
@@ -123,7 +138,7 @@ fun OfflineDetailScreen(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) },
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
@@ -133,7 +148,7 @@ fun OfflineDetailScreen(
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
@@ -169,7 +184,15 @@ fun OfflineDetailScreen(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onPlay(songs, index) },
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
// Titel, die noch fehlen, die Indizes decken sich also nicht.
onPlay = {
val i = spielbar.indexOfFirst { it.id == song.id }
if (i >= 0) onPlay(spielbar, i)
else android.widget.Toast.makeText(
ctx, "Dieser Titel ist noch nicht heruntergeladen",
android.widget.Toast.LENGTH_SHORT).show()
},
)
}
if (editMode) {
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
}
/** Nur FERTIGE Titel: wartende oder fehlgeschlagene haben keine lokale Datei und wuerden aus
* dem Netz gezogen — offline schlaegt die Wiedergabe dann fehl, bei grossen Playlists
* gleich beim ersten Titel. */
suspend fun songsForGroup(groupId: String): List<Song> =
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
offlineRepo.tracksForGroup(groupId)
.filter { it.state == DownloadState.DONE }
.map { it.toSong() }
}