Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ class PlayerSettingsPlaybackControlsTest {
assertEquals(PlayerSettingsAction.SetPreferredCodec("av1"), action.get())
}

@Test
fun mobileDataQualityCanBeLimited() {
val action = AtomicReference<PlayerSettingsAction>()
showSettings(action)

composeRule.onNodeWithText("Mobile data quality").performClick()
composeRule.onNodeWithText("720p").performClick()

assertEquals(PlayerSettingsAction.SetMobileDataQuality("720p"), action.get())
}

@Test
fun doubleTapSeekTimeOffersFiveSeconds() {
val action = AtomicReference<PlayerSettingsAction>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,5 +184,8 @@ class PlaybackNetworkMonitor @Inject constructor(
} else {
null
},
isMetered = activeCapabilities?.let {
!it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
} ?: connectivityManager.isActiveNetworkMetered,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.StateFlow
data class PlaybackNetworkState(
val isAvailable: Boolean,
val generation: Long,
val isMetered: Boolean = false,
)

interface PlaybackNetworkObserver {
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,6 +19,7 @@ internal class PlaybackNetworkStateTracker(
var state = PlaybackNetworkState(
isAvailable = initialRoute.isAvailable,
generation = 0L,
isMetered = initialRoute.isMetered,
)
private set

Expand All @@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
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
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<PlaybackNetworkState>,
private val scope: CoroutineScope,
) {
val states: Flow<PlayerPreferenceState> = combine(local.observe(), server.observe()) {
preferences,
userSettings,
->
val states: Flow<PlayerPreferenceState> = 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,
Expand All @@ -33,7 +43,7 @@ internal class PlayerPreferenceCoordinator(
autoplayCountdownSeconds = preferences.playerAutoplayCountdownSeconds,
audioOnlyPlaybackDefault = preferences.playerAudioOnlyPlayback,
preferredCodec = preferences.playerPreferredCodec,
userSettings = userSettings,
userSettings = userSettings.copy(defaultQuality = automaticQualityCap),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class PlayerViewModel @Inject constructor(
private val playerPreferences = PlayerPreferenceCoordinator(
preferencesRepository,
userSettingsRepository,
networkMonitor.states,
viewModelScope,
)
private val navigationController = PlayerNavigationController(playerHostController, playbackQueueCoordinator)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MediaItem> = 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,
Expand All @@ -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, _ ->
Expand Down
Loading