feat(android): Schalter "Nur über WLAN herunterladen" (Standard an)

Die Downloadarbeit lief mit NetworkType.CONNECTED, also über jede
Verbindung. Das war selbstbegrenzend, solange nach zehn Minuten Schluss
war — mit der automatischen Wiederholung aus 1.9 hätte sich eine große
Playlist unterwegs ununterbrochen weitergeladen (1900 Titel ≈ 9-10 GB).

Neuer Schalter unter "Offline-Downloads", standardmäßig an: dann gilt
UNMETERED, der Download pausiert ohne WLAN und läuft von selbst weiter.
Abschalten nur über eine Rückfrage, die das Datenvolumen benennt.

Beim Speichern werden laufende Gruppen mit REPLACE neu eingereiht —
WorkManager ändert die Bedingungen eingereihter Arbeit nicht mehr, der
Schalter hätte sonst erst beim nächsten App-Start gewirkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-15 22:34:44 +02:00
1 parent e2e7b2490f
commit ffedc7532b
6 files changed
+103 -9

No files matched your search

+13 -2
View File
@@ -60,6 +60,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
| ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ |
| Offline download (albums/playlists) | ❌ | ✅ |
| Downloads nur über WLAN | — | ✅ |
| Offline download (single songs) | ❌ | ✅ |
| Offline playback | ❌ | ✅ |
| Offline detail / manage downloads | ❌ | ✅ |
@@ -76,8 +77,8 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [2.6] — 2026-09-12
**Android:** 1.9 (versionCode 12) → 1.10 (versionCode 13). 1.9 brachte den Besitzer geteilter
Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader.
**Android:** 1.9 (versionCode 12) → 1.11 (versionCode 14). 1.9 brachte den Besitzer geteilter
Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 den WLAN-Schalter.
### Fixed
- **Zurück und Vorwärts gingen im installierten Fenster am PC gar nicht (Web)** — im Browser-Tab
@@ -153,6 +154,16 @@ Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader.
`BundleListRetriever` durch, den die Session seitenweise abholt. Die Größe ist unschuldig.
### Added
- **„Nur über WLAN herunterladen" (Android)** — die Downloadarbeit lief bisher mit
`NetworkType.CONNECTED`, also über **jede** Verbindung. Das war selbstbegrenzend, solange nach zehn
Minuten Schluss war; mit der automatischen Wiederholung hätte sich eine Playlist unterwegs
ununterbrochen weitergeladen — bei 1900 Titeln grob 9–10 GB über Mobilfunk. Neuer Schalter in den
Einstellungen unter **📥 Offline-Downloads**, **standardmäßig an**: dann gilt `UNMETERED`, der
Download pausiert ohne WLAN und läuft von selbst weiter, sobald es zurück ist. Abschalten geht nur
über eine Rückfrage, die das Datenvolumen ausdrücklich benennt.
- Beim Speichern werden laufende Gruppen neu eingereiht (`REPLACE`): WorkManager ändert die
Bedingungen einer bereits eingereihten Arbeit nicht mehr, der Schalter hätte sonst erst beim
nächsten App-Start gewirkt. Unbedenklich, weil der Worker fortsetzt statt neu zu laden.
- **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
und Detailseite zeigen es als `· von <Name>`, und zwar **nur bei fremden** Listen — bei den
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 13
versionName = "1.10"
versionCode = 14
versionName = "1.11"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht.
@@ -27,6 +27,7 @@ class PrefsStore(private val context: Context) {
val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
@@ -56,6 +57,9 @@ class PrefsStore(private val context: Context) {
val absUrl: String = "",
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
val downloadWifiOnly: Boolean = true,
)
data class LastPlayedSong(
@@ -94,6 +98,7 @@ class PrefsStore(private val context: Context) {
absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
)
}
@@ -129,6 +134,7 @@ class PrefsStore(private val context: Context) {
prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
}
}
@@ -161,12 +161,19 @@ class OfflineRepository(context: Context) {
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
*/
private fun starteWorker(groupId: String) {
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
val nurWlan = runCatching {
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
}.getOrDefault(true)
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()
)
@@ -175,8 +182,24 @@ class OfflineRepository(context: Context) {
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
.build()
WorkManager.getInstance(appContext)
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
WorkManager.getInstance(appContext).enqueueUniqueWork(
groupId,
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
request,
)
}
/**
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
* ohnehin fortsetzt statt von vorn zu laden.
*/
fun netzbedingungAktualisieren() {
scope.launch {
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
.forEach { starteWorker(it.groupId, ersetzen = true) }
}
}
/**
@@ -122,6 +122,52 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
)
}
// ── 📥 Downloads ─────────────────────────────────────────────────────
SectionHeader("📥 Offline-Downloads")
var wlanWarnung by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
checked = state.downloadWifiOnly,
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
if (wlanWarnung) {
AlertDialog(
onDismissRequest = { wlanWarnung = false },
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
fontFamily = Outfit, fontSize = 14.sp,
)
},
confirmButton = {
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { wlanWarnung = false }) {
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
}
},
)
}
Spacer(Modifier.height(4.dp))
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
@@ -36,6 +36,7 @@ data class SettingsUiState(
val absUrl: String = "",
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
val downloadWifiOnly: Boolean = true,
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
@@ -46,7 +47,8 @@ data class SettingsUiState(
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val repo = app.app.repository
private val offlineRepo = app.app.offlineRepository
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
@@ -68,6 +70,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl,
absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
)
}
}
@@ -84,6 +87,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
fun onTheme(id: String) {
update { it.copy(theme = id) }
@@ -177,7 +181,11 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(),
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
))
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.
offlineRepo.netzbedingungAktualisieren()
_state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)