From 8d87bbbbaadddfe8384238882fbada025c384b8d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 18 Oct 2026 22:40:48 +0200 Subject: [PATCH 1/2] feat: keep video quality local with mobile cap --- .../PlayerSettingsPlaybackControlsTest.kt | 11 ++++ .../data/network/PlaybackNetworkMonitor.kt | 3 + .../data/network/PlaybackNetworkObserver.kt | 7 ++- .../network/PlaybackNetworkStateTracker.kt | 4 ++ .../DataStorePreferencesRepository.kt | 19 ++++++ .../domain/preferences/AppPreferences.kt | 2 + .../domain/preferences/PlaybackQuality.kt | 48 +++++++++++++++ .../preferences/PreferencesRepository.kt | 2 + .../player/PlayerPreferenceCoordinator.kt | 20 +++++-- .../android/feature/player/PlayerViewModel.kt | 1 + .../settings/player/PlayerSettingsAction.kt | 1 + .../settings/player/PlayerSettingsScreen.kt | 22 ++++++- .../settings/player/PlayerSettingsState.kt | 1 + .../player/PlayerSettingsViewModel.kt | 8 ++- .../services/PlaybackQueueItemResolver.kt | 14 ++++- app/src/main/res/values/strings.xml | 2 + .../PlaybackNetworkStateTrackerTest.kt | 11 ++++ .../DataStorePreferencesRepositoryTest.kt | 4 ++ .../domain/preferences/PlaybackQualityTest.kt | 59 +++++++++++++++++++ 19 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt create mode 100644 app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt index 99204b88..e45b4e2c 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt @@ -27,6 +27,17 @@ class PlayerSettingsPlaybackControlsTest { assertEquals(PlayerSettingsAction.SetPreferredCodec("av1"), action.get()) } + @Test + fun mobileDataQualityCanBeLimited() { + val action = AtomicReference() + showSettings(action) + + composeRule.onNodeWithText("Mobile data quality").performClick() + composeRule.onNodeWithText("720p").performClick() + + assertEquals(PlayerSettingsAction.SetMobileDataQuality("720p"), action.get()) + } + @Test fun doubleTapSeekTimeOffersFiveSeconds() { val action = AtomicReference() diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt index f3a4cdbd..d73652a6 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkMonitor.kt @@ -184,5 +184,8 @@ class PlaybackNetworkMonitor @Inject constructor( } else { null }, + isMetered = activeCapabilities?.let { + !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } ?: connectivityManager.isActiveNetworkMetered, ) } diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt index 5f71f311..2a9b2760 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkObserver.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow data class PlaybackNetworkState( val isAvailable: Boolean, val generation: Long, + val isMetered: Boolean = false, ) interface PlaybackNetworkObserver { @@ -21,7 +22,11 @@ interface NetworkAvailabilityObserver { } internal object AlwaysAvailablePlaybackNetworkObserver : PlaybackNetworkObserver { - private val state = PlaybackNetworkState(isAvailable = true, generation = 0L) + private val state = PlaybackNetworkState( + isAvailable = true, + generation = 0L, + isMetered = false, + ) override fun snapshot(): PlaybackNetworkState = state diff --git a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt index 66efbdd2..d668f90f 100644 --- a/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt +++ b/app/src/main/java/dev/typetype/android/data/network/PlaybackNetworkStateTracker.kt @@ -5,6 +5,7 @@ internal data class PlaybackNetworkRoute( val isBlocked: Boolean, val isValidated: Boolean?, val isSuspended: Boolean?, + val isMetered: Boolean = false, ) { val isAvailable: Boolean get() = identity != null && !isBlocked && isSuspended != true @@ -18,6 +19,7 @@ internal class PlaybackNetworkStateTracker( var state = PlaybackNetworkState( isAvailable = initialRoute.isAvailable, generation = 0L, + isMetered = initialRoute.isMetered, ) private set @@ -38,11 +40,13 @@ internal class PlaybackNetworkStateTracker( previousRoute.identity != nextRoute.identity || previousRoute.isBlocked != nextRoute.isBlocked || previousRoute.isAvailable != nextRoute.isAvailable || + previousRoute.isMetered != nextRoute.isMetered || recovered if (!materiallyChanged) return null return PlaybackNetworkState( isAvailable = nextRoute.isAvailable, generation = state.generation + 1L, + isMetered = nextRoute.isMetered, ).also { state = it } } } diff --git a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt index 089f291a..e78d2b12 100644 --- a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt @@ -17,6 +17,7 @@ import dev.typetype.android.domain.preferences.AppPreferences import dev.typetype.android.domain.preferences.MangaHeadlineMarker import dev.typetype.android.domain.preferences.MangaPaper import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.normalizePlaybackQuality import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.Flow @@ -50,6 +51,12 @@ class DataStorePreferencesRepository @Inject constructor( .takeIf(ALLOWED_DOUBLE_TAP_SEEK_SECONDS::contains) ?: 10, playerPreferredCodec = prefs[KEY_PLAYER_PREFERRED_CODEC] ?.takeIf(ALLOWED_PLAYER_CODECS::contains) ?: "recommended", + playerDefaultQuality = normalizePlaybackQuality( + prefs[KEY_PLAYER_DEFAULT_QUALITY] ?: "1080p", + ), + playerMobileDataQuality = normalizePlaybackQuality( + prefs[KEY_PLAYER_MOBILE_DATA_QUALITY] ?: "1080p", + ), playerSwipeSeekEnabled = prefs[KEY_PLAYER_SWIPE_SEEK] ?: true, playerSwipeBrightnessVolumeEnabled = prefs[KEY_PLAYER_SWIPE_BRIGHT_VOL] ?: true, playerPlaybackBrightnessPercent = prefs[KEY_PLAYER_PLAYBACK_BRIGHTNESS] @@ -114,6 +121,16 @@ class DataStorePreferencesRepository @Inject constructor( dataStore.edit { it[KEY_PLAYER_PREFERRED_CODEC] = value } } + override suspend fun setPlayerDefaultQuality(quality: String) { + dataStore.edit { it[KEY_PLAYER_DEFAULT_QUALITY] = normalizePlaybackQuality(quality) } + } + + override suspend fun setPlayerMobileDataQuality(quality: String) { + dataStore.edit { + it[KEY_PLAYER_MOBILE_DATA_QUALITY] = normalizePlaybackQuality(quality) + } + } + override suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) { dataStore.edit { it[KEY_PLAYER_SWIPE_SEEK] = enabled } } @@ -193,6 +210,8 @@ class DataStorePreferencesRepository @Inject constructor( val KEY_PLAYER_DOUBLE_TAP_SEEK = booleanPreferencesKey("player_double_tap_seek") val KEY_PLAYER_DOUBLE_TAP_SEEK_SECONDS = intPreferencesKey("player_double_tap_seek_seconds") val KEY_PLAYER_PREFERRED_CODEC = stringPreferencesKey("player_preferred_codec") + val KEY_PLAYER_DEFAULT_QUALITY = stringPreferencesKey("player_default_quality") + val KEY_PLAYER_MOBILE_DATA_QUALITY = stringPreferencesKey("player_mobile_data_quality") val KEY_PLAYER_SWIPE_SEEK = booleanPreferencesKey("player_swipe_seek") val KEY_PLAYER_SWIPE_BRIGHT_VOL = booleanPreferencesKey("player_swipe_bright_vol") val KEY_PLAYER_PLAYBACK_BRIGHTNESS = intPreferencesKey("player_playback_brightness") diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt index bd4257d2..6949806a 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt @@ -18,6 +18,8 @@ data class AppPreferences( val playerDoubleTapSeekEnabled: Boolean = true, val playerDoubleTapSeekSeconds: Int = 10, val playerPreferredCodec: String = "recommended", + val playerDefaultQuality: String = "1080p", + val playerMobileDataQuality: String = "1080p", val playerSwipeSeekEnabled: Boolean = true, val playerSwipeBrightnessVolumeEnabled: Boolean = true, val playerPlaybackBrightnessPercent: Int? = null, diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt b/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt new file mode 100644 index 00000000..f561bbb6 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/PlaybackQuality.kt @@ -0,0 +1,48 @@ +package dev.typetype.android.domain.preferences + +private val QUALITY_HEIGHT = Regex("""(\d{3,4})p""", RegexOption.IGNORE_CASE) + +val PLAYBACK_QUALITY_OPTIONS = listOf( + "auto", + "144p", + "240p", + "360p", + "480p", + "720p", + "1080p", + "1440p", + "2160p", +) + +val PLAYBACK_MOBILE_QUALITY_OPTIONS = listOf( + "480p", + "720p", + "1080p", + "1440p", + "2160p", +) + +fun normalizePlaybackQuality(value: String): String { + val cleaned = value.trim().lowercase() + if (cleaned == "auto" || cleaned == "best") return "auto" + val height = QUALITY_HEIGHT.find(cleaned)?.groupValues?.get(1)?.toIntOrNull() + ?: return "1080p" + return "${height}p" +} + +fun effectivePlaybackQualityCap( + defaultQuality: String, + mobileDataQuality: String, + isMetered: Boolean, +): String { + val normalizedDefault = normalizePlaybackQuality(defaultQuality) + if (!isMetered) return normalizedDefault.capHeightLabel() + val normalizedMobile = normalizePlaybackQuality(mobileDataQuality) + return "${minOf(normalizedDefault.capHeight(), normalizedMobile.capHeight())}p" +} + +private fun String.capHeight(): Int = + if (this == "auto") 2160 else substringBefore('p').toIntOrNull() ?: 1080 + +private fun String.capHeightLabel(): String = + if (this == "auto") "2160p" else this diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt index f5504d2d..04f03546 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt @@ -21,6 +21,8 @@ interface PreferencesRepository { suspend fun setPlayerDoubleTapSeekEnabled(enabled: Boolean) suspend fun setPlayerDoubleTapSeekSeconds(seconds: Int) suspend fun setPlayerPreferredCodec(codec: String) + suspend fun setPlayerDefaultQuality(quality: String) + suspend fun setPlayerMobileDataQuality(quality: String) suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) suspend fun setPlayerSwipeBrightnessVolumeEnabled(enabled: Boolean) suspend fun setPlayerPlaybackBrightnessPercent(percent: Int) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt index 7ba9be2c..5e56d471 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt @@ -1,6 +1,8 @@ package dev.typetype.android.feature.player +import dev.typetype.android.data.network.PlaybackNetworkState import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.effectivePlaybackQualityCap import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.components.PlayerGestureConfig @@ -8,18 +10,26 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch internal class PlayerPreferenceCoordinator( private val local: PreferencesRepository, private val server: UserSettingsRepository, + private val networkStates: StateFlow, private val scope: CoroutineScope, ) { - val states: Flow = combine(local.observe(), server.observe()) { - preferences, - userSettings, - -> + val states: Flow = combine( + local.observe(), + server.observe(), + networkStates, + ) { preferences, userSettings, network -> + val automaticQualityCap = effectivePlaybackQualityCap( + defaultQuality = preferences.playerDefaultQuality, + mobileDataQuality = preferences.playerMobileDataQuality, + isMetered = network.isMetered, + ) PlayerPreferenceState( gestureConfig = PlayerGestureConfig( doubleTapSeekEnabled = preferences.playerDoubleTapSeekEnabled, @@ -33,7 +43,7 @@ internal class PlayerPreferenceCoordinator( autoplayCountdownSeconds = preferences.playerAutoplayCountdownSeconds, audioOnlyPlaybackDefault = preferences.playerAudioOnlyPlayback, preferredCodec = preferences.playerPreferredCodec, - userSettings = userSettings, + userSettings = userSettings.copy(defaultQuality = automaticQualityCap), ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt index b4175c22..6576788d 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt @@ -67,6 +67,7 @@ class PlayerViewModel @Inject constructor( private val playerPreferences = PlayerPreferenceCoordinator( preferencesRepository, userSettingsRepository, + networkMonitor.states, viewModelScope, ) private val navigationController = PlayerNavigationController(playerHostController, playbackQueueCoordinator) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt index 5afda711..ef63e0e5 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt @@ -20,6 +20,7 @@ sealed interface PlayerSettingsAction { data class SetDanmakuSpeed(val speed: Float) : PlayerSettingsAction data class SetDanmakuSize(val size: Float) : PlayerSettingsAction data class SetDefaultQuality(val quality: String) : PlayerSettingsAction + data class SetMobileDataQuality(val quality: String) : PlayerSettingsAction data class SetDefaultPlaybackSpeed(val speed: Double) : PlayerSettingsAction data class SetDefaultService(val service: Int) : PlayerSettingsAction data class SetSubtitlesEnabled(val enabled: Boolean) : PlayerSettingsAction diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt index 11927495..0ab9fb6f 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt @@ -40,13 +40,14 @@ import dev.typetype.android.core.ui.components.DropdownRow import dev.typetype.android.core.ui.components.ServiceRow import dev.typetype.android.core.ui.components.SettingsSectionHeader import dev.typetype.android.core.ui.components.SwitchRow +import dev.typetype.android.domain.preferences.PLAYBACK_MOBILE_QUALITY_OPTIONS +import dev.typetype.android.domain.preferences.PLAYBACK_QUALITY_OPTIONS import dev.typetype.android.feature.player.AV1_CODEC_KEY import dev.typetype.android.feature.player.H264_CODEC_KEY import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY import dev.typetype.android.feature.player.VP9_CODEC_KEY import dev.typetype.android.feature.settings.SettingsDetailTopBar -private val QUALITY_OPTIONS = listOf("144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p") private val AUTOPLAY_COUNTDOWN_OPTIONS = listOf(0, 5, 10, 15, 30, 60) private val PLAYBACK_SPEED_OPTIONS = listOf(0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0) @@ -194,11 +195,28 @@ fun PlayerSettingsScreen( DropdownRow( title = stringResource(R.string.settings_player_default_quality), subtitle = stringResource(R.string.settings_player_default_quality_subtitle), - options = QUALITY_OPTIONS.map { it to it }, + options = PLAYBACK_QUALITY_OPTIONS.map { quality -> + quality to if (quality == "auto") { + stringResource(R.string.playback_options_auto) + } else { + quality + } + }, selectedKey = state.defaultQuality, onSelect = { onAction(PlayerSettingsAction.SetDefaultQuality(it)) }, ) } + item { + DropdownRow( + title = stringResource(R.string.settings_player_mobile_quality), + subtitle = stringResource(R.string.settings_player_mobile_quality_subtitle), + options = PLAYBACK_MOBILE_QUALITY_OPTIONS.map { it to it }, + selectedKey = state.mobileDataQuality, + onSelect = { + onAction(PlayerSettingsAction.SetMobileDataQuality(it)) + }, + ) + } item { DropdownRow( title = stringResource(R.string.settings_player_default_speed), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt index 4f120a34..20334587 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt @@ -21,6 +21,7 @@ data class PlayerSettingsState( val danmakuSpeed: Float = 1f, val danmakuSize: Float = 1f, val defaultQuality: String = "1080p", + val mobileDataQuality: String = "1080p", val defaultPlaybackSpeed: Double = 1.0, val defaultService: Int = 0, val subtitlesEnabled: Boolean = false, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt index 3b64a1ca..5abda51a 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt @@ -7,7 +7,6 @@ import dev.typetype.android.domain.preferences.PreferencesRepository import dev.typetype.android.domain.usersettings.DEFAULT_SPONSOR_BLOCK_CATEGORY_ACTIONS import dev.typetype.android.domain.usersettings.SponsorBlockMode import dev.typetype.android.domain.usersettings.UserSettings -import dev.typetype.android.domain.usersettings.normalizeQualityName import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -47,7 +46,8 @@ class PlayerSettingsViewModel @Inject constructor( danmakuEnabled = prefs.danmakuEnabled, danmakuSpeed = prefs.danmakuSpeed, danmakuSize = prefs.danmakuSize, - defaultQuality = normalizeQualityName(server.defaultQuality), + defaultQuality = prefs.playerDefaultQuality, + mobileDataQuality = prefs.playerMobileDataQuality, defaultPlaybackSpeed = server.defaultPlaybackSpeed, defaultService = server.defaultService, subtitlesEnabled = server.subtitlesEnabled, @@ -105,7 +105,9 @@ class PlayerSettingsViewModel @Inject constructor( is PlayerSettingsAction.SetAudioOnlyPlayback -> preferencesRepository.setPlayerAudioOnlyPlayback(action.enabled) is PlayerSettingsAction.SetDefaultQuality -> - updateServer { it.copy(defaultQuality = action.quality) } + preferencesRepository.setPlayerDefaultQuality(action.quality) + is PlayerSettingsAction.SetMobileDataQuality -> + preferencesRepository.setPlayerMobileDataQuality(action.quality) is PlayerSettingsAction.SetDefaultPlaybackSpeed -> updateServer { it.copy(defaultPlaybackSpeed = action.speed.coerceIn(0.25, 4.0)) } is PlayerSettingsAction.SetDefaultService -> diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt index 7a73ac36..2ce42af8 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt @@ -3,6 +3,9 @@ package dev.typetype.android.services import android.content.Context import androidx.media3.common.MediaItem import dagger.hilt.android.qualifiers.ApplicationContext +import dev.typetype.android.data.network.PlaybackNetworkMonitor +import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.preferences.effectivePlaybackQualityCap import dev.typetype.android.domain.stream.SabrPlaybackRepository import dev.typetype.android.domain.stream.StreamRepository import dev.typetype.android.domain.stream.sabrPlaybackTarget @@ -15,18 +18,27 @@ import dev.typetype.android.feature.player.pickPlayableSource import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first @Singleton class PlaybackQueueItemResolver @Inject constructor( @ApplicationContext context: Context, private val streamRepository: StreamRepository, private val userSettingsRepository: UserSettingsRepository, + private val preferencesRepository: PreferencesRepository, + private val networkMonitor: PlaybackNetworkMonitor, private val sabrPlaybackRepository: SabrPlaybackRepository, ) { private val codecSupport = DevicePlaybackCodecSupport(context) suspend fun resolve(videoUrl: String): Result = captureQueueResult { val settings = userSettingsRepository.current().getOrThrow() + val preferences = preferencesRepository.observe().first() + val automaticQualityCap = effectivePlaybackQualityCap( + defaultQuality = preferences.playerDefaultQuality, + mobileDataQuality = preferences.playerMobileDataQuality, + isMetered = networkMonitor.snapshot().isMetered, + ) val stream = streamRepository.loadPlaybackStream(videoUrl).getOrThrow() val selectedAudioKey = stream.initialAudioKey( settings.defaultAudioLanguage, @@ -38,7 +50,7 @@ class PlaybackQueueItemResolver @Inject constructor( selectedQuality = stream.initialQuality(), selectedAudioKey = selectedAudioKey, defaultAudioLanguage = settings.defaultAudioLanguage, - automaticQualityCap = settings.defaultQuality, + automaticQualityCap = automaticQualityCap, preferOriginalLanguage = settings.preferOriginalLanguage, codecSupport = codecSupport, prepareSabrPlayback = { loaded, selection, _ -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1333383a..782c59bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -302,6 +302,8 @@ Default codec Smart prefers efficient hardware decoding Preferred video resolution + Mobile data quality + Maximum quality on mobile data Default playback speed Applied when a video starts Subtitles diff --git a/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt b/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt index b03d6da0..1e98ef78 100644 --- a/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt +++ b/app/src/test/java/dev/typetype/android/data/network/PlaybackNetworkStateTrackerTest.kt @@ -87,6 +87,15 @@ class PlaybackNetworkStateTrackerTest { assertEquals(PlaybackNetworkState(true, 1L), changed) } + @Test + fun `metered change advances generation`() { + val tracker = tracker(route(metered = false)) + + val changed = tracker.update(route(metered = true)) + + assertEquals(PlaybackNetworkState(true, 1L, isMetered = true), changed) + } + private fun tracker(route: PlaybackNetworkRoute) = PlaybackNetworkStateTracker(route) private fun route( @@ -94,10 +103,12 @@ class PlaybackNetworkStateTrackerTest { blocked: Boolean = false, validated: Boolean? = true, suspended: Boolean? = false, + metered: Boolean = false, ) = PlaybackNetworkRoute( identity = identity, isBlocked = blocked, isValidated = validated, isSuspended = suspended, + isMetered = metered, ) } diff --git a/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt index 31528267..0d03e5e8 100644 --- a/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt +++ b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt @@ -70,6 +70,8 @@ class DataStorePreferencesRepositoryTest { ) first.setPlayerDoubleTapSeekSeconds(5) first.setPlayerPreferredCodec("vp9") + first.setPlayerDefaultQuality("2160p") + first.setPlayerMobileDataQuality("720p") first.setPlayerAccessibleControlsEnabled(true) firstJob.cancelAndJoin() @@ -82,6 +84,8 @@ class DataStorePreferencesRepositoryTest { assertEquals(5, preferences.playerDoubleTapSeekSeconds) assertEquals("vp9", preferences.playerPreferredCodec) + assertEquals("2160p", preferences.playerDefaultQuality) + assertEquals("720p", preferences.playerMobileDataQuality) assertEquals(true, preferences.playerAccessibleControlsEnabled) secondJob.cancelAndJoin() directory.deleteRecursively() diff --git a/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt b/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt new file mode 100644 index 00000000..3c78a61e --- /dev/null +++ b/app/src/test/java/dev/typetype/android/domain/preferences/PlaybackQualityTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.android.domain.preferences + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackQualityTest { + @Test + fun `auto keeps the highest quality on unmetered networks`() { + assertEquals( + "2160p", + effectivePlaybackQualityCap( + defaultQuality = "auto", + mobileDataQuality = "1080p", + isMetered = false, + ), + ) + } + + @Test + fun `auto uses the mobile cap on metered networks`() { + assertEquals( + "1080p", + effectivePlaybackQualityCap( + defaultQuality = "auto", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `a lower default quality stays below the mobile cap`() { + assertEquals( + "720p", + effectivePlaybackQualityCap( + defaultQuality = "720p", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `a higher default quality is capped on mobile`() { + assertEquals( + "1080p", + effectivePlaybackQualityCap( + defaultQuality = "2160p", + mobileDataQuality = "1080p", + isMetered = true, + ), + ) + } + + @Test + fun `quality names accept hdr suffixes`() { + assertEquals("1440p", normalizePlaybackQuality("1440p HDR")) + } +} From 6b4d43389369817afc4a8d6cf50bd1c7c81671b0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 19 Oct 2026 08:00:03 +0200 Subject: [PATCH 2/2] build: prepare mobile beta 1.10.0-beta.3 --- app/build.gradle.kts | 4 ++-- release-notes/v1.10.0-beta.3.md | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.10.0-beta.3.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 258d04db..cdcd7256 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 11002 - versionName = "1.10.0-beta.2" + versionCode = 11003 + versionName = "1.10.0-beta.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.10.0-beta.3.md b/release-notes/v1.10.0-beta.3.md new file mode 100644 index 00000000..b479d0ee --- /dev/null +++ b/release-notes/v1.10.0-beta.3.md @@ -0,0 +1,11 @@ +TypeType Android 1.10.0-beta.3 + +- Choose the default video quality locally on your device, with Auto and 144p + through 2160p. +- Cap automatic playback on mobile data at 1080p by default, with a + configurable limit. +- Keep quality changes on the device instead of synchronizing them to the + server. +- Apply the same local quality policy to regular videos, Shorts, and queued + playback. +- Includes the playback recovery improvements from 1.10.0-beta.2.