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:
1 parent
e2e7b2490f
commit
ffedc7532b
6 files changed
+103
-9
No files matched your search
+13
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user