mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
address network quality review findings
- fall back to the legacy max_bitrate/stream_format keys so existing settings survive the upgrade - keep the last network mode when there is no active network instead of reporting mobile, so a connectivity gap does not flip modes and cause spurious reloads - skip the quality reload when the player has ended or advanced mid-read - keep the reload collector alive across transient failures - edit stream quality from a local dialog draft so a fast second tap is not lost - cover the mode mapping, migration, paused reload and equal-quality no-op with tests
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package com.subtracks.data.net
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
enum class NetworkMode(
|
||||
val key: String,
|
||||
) {
|
||||
Wifi("wifi"),
|
||||
Mobile("mobile"),
|
||||
}
|
||||
|
||||
internal fun networkModeFor(
|
||||
metered: Boolean?,
|
||||
last: NetworkMode,
|
||||
): NetworkMode =
|
||||
when (metered) {
|
||||
true -> NetworkMode.Mobile
|
||||
false -> NetworkMode.Wifi
|
||||
null -> last
|
||||
}
|
||||
|
||||
fun networkMode(context: Context): Flow<NetworkMode> {
|
||||
val connectivity = context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
||||
return callbackFlow {
|
||||
var last = NetworkMode.Wifi
|
||||
|
||||
fun push() {
|
||||
val metered = connectivity?.let { if (it.activeNetwork == null) null else it.isActiveNetworkMetered }
|
||||
last = networkModeFor(metered, last)
|
||||
this@callbackFlow.trySend(last)
|
||||
}
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = push()
|
||||
|
||||
override fun onLost(network: Network) = push()
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) = push()
|
||||
}
|
||||
val registered = runCatching { connectivity?.registerDefaultNetworkCallback(callback) }.isSuccess
|
||||
push()
|
||||
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.subtracks.data.net
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
enum class NetworkMode(
|
||||
val key: String,
|
||||
) {
|
||||
Wifi("wifi"),
|
||||
Mobile("mobile"),
|
||||
}
|
||||
|
||||
class NetworkMonitor(
|
||||
context: Context,
|
||||
) {
|
||||
private val connectivity = context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
||||
|
||||
val mode: Flow<NetworkMode> =
|
||||
callbackFlow {
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) = push()
|
||||
|
||||
override fun onLost(network: Network) = push()
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) = push()
|
||||
|
||||
private fun push() {
|
||||
this@callbackFlow.trySend(current())
|
||||
}
|
||||
}
|
||||
val registered = runCatching { connectivity?.registerDefaultNetworkCallback(callback) }.isSuccess
|
||||
this@callbackFlow.trySend(current())
|
||||
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
||||
}.distinctUntilChanged().conflate()
|
||||
|
||||
private fun current(): NetworkMode = if (connectivity?.isActiveNetworkMetered == true) NetworkMode.Mobile else NetworkMode.Wifi
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
@@ -54,7 +55,9 @@ class UserPreferences(
|
||||
}
|
||||
|
||||
fun streamQuality(mode: NetworkMode): Flow<StreamQuality> =
|
||||
store.data.map { prefs -> decodeStreamQuality(prefs[streamQualityKey(mode)]) }
|
||||
store.data.map { prefs ->
|
||||
prefs[streamQualityKey(mode)]?.let(::decodeStreamQuality) ?: legacyStreamQuality(prefs)
|
||||
}
|
||||
|
||||
suspend fun setStreamQuality(
|
||||
mode: NetworkMode,
|
||||
@@ -71,14 +74,20 @@ class UserPreferences(
|
||||
|
||||
private fun encode(quality: StreamQuality) = "${quality.maxBitrate}|${quality.format.orEmpty()}"
|
||||
|
||||
private fun decodeStreamQuality(stored: String?): StreamQuality {
|
||||
val parts = stored?.split("|") ?: return StreamQuality()
|
||||
private fun decodeStreamQuality(stored: String): StreamQuality {
|
||||
val parts = stored.split("|")
|
||||
return StreamQuality(
|
||||
maxBitrate = parts.getOrNull(0)?.toIntOrNull() ?: 0,
|
||||
format = parts.getOrNull(1)?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyStreamQuality(prefs: Preferences): StreamQuality =
|
||||
StreamQuality(
|
||||
maxBitrate = prefs[LEGACY_BITRATE] ?: 0,
|
||||
format = prefs[LEGACY_FORMAT]?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
|
||||
private fun decode(
|
||||
stored: String?,
|
||||
defaultSort: String,
|
||||
@@ -91,6 +100,11 @@ class UserPreferences(
|
||||
starred = StarredFilter.entries.getOrElse(parts[2].toIntOrNull() ?: 0) { StarredFilter.Any },
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LEGACY_BITRATE = intPreferencesKey("max_bitrate")
|
||||
val LEGACY_FORMAT = stringPreferencesKey("stream_format")
|
||||
}
|
||||
}
|
||||
|
||||
fun createUserPreferences(context: Context): UserPreferences = UserPreferences(context.preferences)
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.subtracks.data.db.createAndroidDatabase
|
||||
import com.subtracks.data.net.NetworkMonitor
|
||||
import com.subtracks.data.net.networkMode
|
||||
import com.subtracks.data.prefs.createUserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedRepository
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
@@ -39,8 +39,7 @@ fun appModule(
|
||||
single { createAndroidDatabase(context) }
|
||||
single { http }
|
||||
single { createUserPreferences(context) }
|
||||
single { NetworkMonitor(context.applicationContext) }
|
||||
single { SourceRepository(get(), get(), get(), networkMode = get<NetworkMonitor>().mode, showMessage = toast) }
|
||||
single { SourceRepository(get(), get(), get(), networkMode = networkMode(context.applicationContext), showMessage = toast) }
|
||||
single { LibraryRepository(get(), get(), toast) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get()) }
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -135,7 +136,14 @@ class PlaybackController(
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
sourceRepository.quality.drop(1).collect { reloadQuality() }
|
||||
sourceRepository.quality.drop(1).collect {
|
||||
try {
|
||||
reloadQuality()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,8 +718,12 @@ class PlaybackController(
|
||||
if (snapshot.size == 0L) return@withLock
|
||||
val sourceId = queueSourceId
|
||||
if (sourceId == null || sourceRepository.activeSourceIdOnce() != sourceId) return@withLock
|
||||
val position = currentPosition() ?: return@withLock
|
||||
loadWindow(position, autoplay = player.playWhenReady && !player.isEnded, startPositionMs = player.currentPositionMs)
|
||||
if (player.isEnded) return@withLock
|
||||
val index = player.currentIndex
|
||||
val position = (windowStart + index).coerceIn(0, snapshot.size - 1)
|
||||
val positionMs = player.currentPositionMs
|
||||
if (player.currentIndex != index) return@withLock
|
||||
loadWindow(position, autoplay = player.playWhenReady, startPositionMs = positionMs)
|
||||
}
|
||||
|
||||
private fun jumpTo(target: Long) {
|
||||
|
||||
@@ -204,6 +204,7 @@ private fun QualityDialog(
|
||||
onSelect: (StreamQuality) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var draft by remember(quality) { mutableStateOf(quality) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
@@ -212,15 +213,21 @@ private fun QualityDialog(
|
||||
ChoiceGroup(
|
||||
header = "Maximum bitrate",
|
||||
options = bitrateOptions.map { it to bitrateLabel(it) },
|
||||
selected = quality.maxBitrate,
|
||||
onSelect = { onSelect(quality.copy(maxBitrate = it)) },
|
||||
selected = draft.maxBitrate,
|
||||
onSelect = {
|
||||
draft = draft.copy(maxBitrate = it)
|
||||
onSelect(draft)
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChoiceGroup(
|
||||
header = "Preferred format",
|
||||
options = streamFormats.map { it to (it ?: "Use server default") },
|
||||
selected = quality.format,
|
||||
onSelect = { onSelect(quality.copy(format = it)) },
|
||||
selected = draft.format,
|
||||
onSelect = {
|
||||
draft = draft.copy(format = it)
|
||||
onSelect(draft)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user