diff --git a/CHANGELOG.md b/CHANGELOG.md index 427acd2..915134a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `, und zwar **nur bei fremden** Listen — bei den diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1185393..3a12964 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt index 17b97dc..eea0552 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/PrefsStore.kt @@ -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 } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt index 6b39a43..c0b7563 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt @@ -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() .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) } + } } /** diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt index 4828d13..2f529c7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/SettingsScreen.kt @@ -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", diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt index 71fb74c..c8d9faa 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SettingsViewModel.kt @@ -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)