feat: add quality selection and preference settings

This commit is contained in:
tunbmoon
2026-02-13 23:53:05 +08:00
parent bcac22c1de
commit 4782ea59ae
12 changed files with 658 additions and 114 deletions
@@ -26,6 +26,8 @@ class PlayerRepository(private val context: Context) {
fun getMirrorFlip(): Boolean = SettingsManager.getMirrorFlip(context)
fun getQualityPreference(): Int = SettingsManager.getQualityPreference(context)
fun setEnablePip(value: Boolean) = SettingsManager.setEnablePip(context, value)
fun setBackgroundPlay(value: Boolean) = SettingsManager.setBackgroundPlay(context, value)
@@ -6,6 +6,7 @@ import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import com.antoniegil.astronia.util.NetworkUtils
import androidx.media3.common.Tracks
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -24,7 +25,7 @@ class Media3Player(private val context: Context) {
var onBufferingListener: ((Boolean) -> Unit)? = null
var onPlaybackStateChanged: ((isPlaying: Boolean, position: Long, bufferedPosition: Long, duration: Long) -> Unit)? = null
var onErrorListener: ((error: String, isRetriable: Boolean) -> Unit)? = null
var onRenderedFirstFrameListener: (() -> Unit)? = null
var onTracksChangedListener: ((Tracks) -> Unit)? = null
init {
NetworkUtils.setupAndroid7SSL()
@@ -52,6 +53,15 @@ class Media3Player(private val context: Context) {
latencyMonitor = PlayerFactory.createLatencyMonitor { exoPlayer },
combinedListener = PlayerListeners.createCombinedListener({ exoPlayer }, state, callbacks)
)
exoPlayer?.addListener(object : androidx.media3.common.Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
onTracksChangedListener?.invoke(tracks)
}
override fun onTrackSelectionParametersChanged(parameters: androidx.media3.common.TrackSelectionParameters) {
exoPlayer?.currentTracks?.let { onTracksChangedListener?.invoke(it) }
}
})
}
fun attachSurface(surface: Surface?) {
@@ -0,0 +1,203 @@
package com.antoniegil.astronia.player
import android.os.Handler
import android.os.Looper
import androidx.media3.common.C
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.exoplayer.ExoPlayer
import com.antoniegil.astronia.R
data class VideoQuality(
val width: Int,
val height: Int,
val bitrate: Int,
val pixelWidthHeightRatio: Float = 1.0f,
val id: String,
val labelSuffix: String = ""
) {
val label: String
get() {
return when {
height > 0 -> "${height}p"
width > 0 -> "${width}w"
bitrate > 0 -> "${bitrate / 1000}k"
else -> R.string.unknown
}.let { base ->
(if (labelSuffix.isNotEmpty()) "$base ($labelSuffix)" else base) as String
}
}
val isBitrateOnly: Boolean
get() = height <= 0 && width <= 0 && bitrate > 0
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
object QualityManager {
fun parseQualities(tracks: Tracks, trackSelectionParameters: TrackSelectionParameters): Pair<List<VideoQuality>, VideoQuality?> {
val qualities = mutableListOf<VideoQuality>()
var currentSelectedQuality: VideoQuality? = null
val videoGroupType = C.TRACK_TYPE_VIDEO
for (group in tracks.groups) {
if (group.type == videoGroupType) {
for (i in 0 until group.length) {
if (group.isTrackSupported(i)) {
val format = group.getTrackFormat(i)
val id = format.id ?: "${format.width}x${format.height}@${format.bitrate}"
val quality = VideoQuality(
width = format.width,
height = format.height,
bitrate = format.bitrate,
pixelWidthHeightRatio = format.pixelWidthHeightRatio,
id = id
)
if (quality.height > 0 || quality.width > 0 || quality.bitrate > 0) {
qualities.add(quality)
}
if (group.isTrackSelected(i)) {
currentSelectedQuality = quality
}
}
}
}
}
val qualitiesWithLabels = qualities.groupBy { it.label }.flatMap { (_, sameLabelQualities) ->
val qualitiesWithSuffixes = sameLabelQualities.map { quality ->
val ratio = if (quality.height > 0) {
(quality.width * quality.pixelWidthHeightRatio) / quality.height
} else 0f
val suffix = when {
kotlin.math.abs(ratio - 1.77f) < 0.1f -> "16:9"
kotlin.math.abs(ratio - 1.33f) < 0.15f -> "4:3"
kotlin.math.abs(ratio - 2.33f) < 0.1f -> "21:9"
ratio > 0 -> String.format("%.2f:1", ratio)
else -> ""
}
Pair(quality, suffix)
}
val distinctSuffixes = qualitiesWithSuffixes.map { it.second }.filter { it.isNotEmpty() }.distinct()
if (distinctSuffixes.size > 1) {
qualitiesWithSuffixes.map { (quality, suffix) ->
if (suffix.isNotEmpty()) quality.copy(labelSuffix = suffix) else quality
}
} else {
sameLabelQualities
}
}
val uniqueQualities = qualitiesWithLabels
.groupBy {
if (it.isBitrateOnly) it.id else "${it.width}x${it.height}"
}
.map { (_, sameResQualities) ->
sameResQualities.maxByOrNull { it.bitrate } ?: sameResQualities.first()
}
.sortedWith(compareByDescending<VideoQuality> { it.height }
.thenByDescending { it.bitrate }
.thenByDescending { it.width })
val finalCurrentQuality = if (currentSelectedQuality != null) {
uniqueQualities.find {
it.width == currentSelectedQuality.width &&
it.height == currentSelectedQuality.height &&
it.bitrate == currentSelectedQuality.bitrate
} ?: currentSelectedQuality
} else null
val hasOverride = tracks.groups.any { group ->
group.type == C.TRACK_TYPE_VIDEO && trackSelectionParameters.overrides.containsKey(group.mediaTrackGroup)
}
return Pair(uniqueQualities, if (hasOverride) finalCurrentQuality else null)
}
fun setQuality(player: ExoPlayer?, quality: VideoQuality?) {
val exoPlayer = player ?: return
val currentParams = exoPlayer.trackSelectionParameters
if (quality == null) {
val hasOverride = exoPlayer.currentTracks.groups.any { group ->
group.type == C.TRACK_TYPE_VIDEO && currentParams.overrides.containsKey(group.mediaTrackGroup)
}
if (!hasOverride) {
return
}
exoPlayer.trackSelectionParameters = currentParams
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
.build()
return
}
val tracks = exoPlayer.currentTracks
for (group in tracks.groups) {
if (group.type == C.TRACK_TYPE_VIDEO) {
for (i in 0 until group.length) {
val format = group.getTrackFormat(i)
val isMatch = if (quality.isBitrateOnly) {
format.bitrate == quality.bitrate
} else {
format.width == quality.width &&
format.height == quality.height &&
format.bitrate == quality.bitrate &&
kotlin.math.abs(format.pixelWidthHeightRatio - quality.pixelWidthHeightRatio) < 0.01f
}
if (isMatch) {
val existingOverride = currentParams.overrides[group.mediaTrackGroup]
if (existingOverride != null &&
existingOverride.trackIndices.size == 1 &&
existingOverride.trackIndices[0] == i) {
return
}
val newParams = currentParams
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
.setOverrideForType(
TrackSelectionOverride(
group.mediaTrackGroup,
listOf(i)
)
)
.build()
exoPlayer.trackSelectionParameters = newParams
return
}
}
}
}
}
fun selectQualityByPreference(qualities: List<VideoQuality>, preference: Int): VideoQuality? {
if (qualities.isEmpty()) return null
if (qualities.size == 1) return null
val result = when (preference) {
0 -> qualities.firstOrNull()
1 -> {
val isBitrateMode = qualities.firstOrNull()?.isBitrateOnly == true
if (isBitrateMode) {
qualities.firstOrNull { it.bitrate in 1290000..2540000 }
?: qualities.lastOrNull()
} else {
qualities.firstOrNull { it.height in 481..719 }
?: qualities.lastOrNull()
}
}
else -> null
}
return result
}
}
@@ -14,13 +14,16 @@ import android.util.Rational
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.*
@@ -34,10 +37,13 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
import com.antoniegil.astronia.player.Media3Player
import com.antoniegil.astronia.player.VideoQuality
import com.antoniegil.astronia.util.WatchTimeTracker
import kotlinx.coroutines.delay
import com.antoniegil.astronia.R
import com.antoniegil.astronia.player.QualityManager
@Composable
@@ -325,7 +331,10 @@ fun PlayerSettingsBottomSheet(
onBackgroundPlayChange: (Boolean) -> Unit,
onAspectRatioChange: (Int) -> Unit,
onMirrorFlipChange: (Boolean) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
availableQualities: List<VideoQuality> = emptyList(),
currentQuality: VideoQuality? = null,
onQualityChange: (VideoQuality?) -> Unit = {}
) {
val context = LocalContext.current
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -345,92 +354,116 @@ fun PlayerSettingsBottomSheet(
val backgroundPlayInteractionSource = remember { MutableInteractionSource() }
var currentScreen by remember { mutableStateOf(SettingsScreen.MAIN) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetMaxWidth = 640.dp,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
androidx.compose.animation.AnimatedContent(
targetState = currentScreen,
label = "settings_navigation"
) { screen ->
when (screen) {
SettingsScreen.MAIN -> {
MainSettingsContent(
isPipSupported = isPipSupported,
enablePip = enablePip,
onEnablePipChange = onEnablePipChange,
backgroundPlay = backgroundPlay,
isBatteryOptimized = isBatteryOptimized,
backgroundPlayInteractionSource = backgroundPlayInteractionSource,
onBackgroundPlayChange = onBackgroundPlayChange,
launcher = launcher,
intent = intent,
aspectRatio = aspectRatio,
onAspectRatioChange = onAspectRatioChange,
mirrorFlip = mirrorFlip,
onMirrorFlipChange = onMirrorFlipChange,
availableQualities = availableQualities,
currentQuality = currentQuality,
onNavigateToQuality = { currentScreen = SettingsScreen.QUALITY }
)
}
SettingsScreen.QUALITY -> {
QualitySelectionContent(
availableQualities = availableQualities,
currentQuality = currentQuality,
onQualityChange = {
onQualityChange(it)
currentScreen = SettingsScreen.MAIN
},
onBack = { currentScreen = SettingsScreen.MAIN }
)
}
}
}
}
}
private enum class SettingsScreen {
MAIN, QUALITY
}
@Composable
private fun MainSettingsContent(
isPipSupported: Boolean,
enablePip: Boolean,
onEnablePipChange: (Boolean) -> Unit,
backgroundPlay: Boolean,
isBatteryOptimized: Boolean,
backgroundPlayInteractionSource: MutableInteractionSource,
onBackgroundPlayChange: (Boolean) -> Unit,
launcher: androidx.activity.compose.ManagedActivityResultLauncher<Intent, androidx.activity.result.ActivityResult>,
intent: Intent,
aspectRatio: Int,
onAspectRatioChange: (Int) -> Unit,
mirrorFlip: Boolean,
onMirrorFlipChange: (Boolean) -> Unit,
availableQualities: List<VideoQuality>,
currentQuality: VideoQuality?,
onNavigateToQuality: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 20.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 20.dp, vertical = 8.dp)
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
listOf(
0 to "16:9",
1 to "4:3",
2 to R.string.aspect_ratio_fill,
3 to R.string.aspect_ratio_original
).forEach { (value, label) ->
FilterChip(
selected = aspectRatio == value,
onClick = { onAspectRatioChange(value) },
label = {
Text(
if (label is Int) stringResource(label) else label as String
)
}
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (isPipSupported) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PictureInPicture,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp).size(24.dp)
)
listOf(
0 to "16:9",
1 to "4:3",
2 to R.string.aspect_ratio_fill,
3 to R.string.aspect_ratio_original
).forEach { (value, label) ->
FilterChip(
selected = aspectRatio == value,
onClick = { onAspectRatioChange(value) },
label = {
Text(
text = stringResource(R.string.pip),
style = MaterialTheme.typography.bodyLarge
)
if (label is Int) stringResource(label) else label as String
)
}
val thumbContent: (@Composable () -> Unit)? = if (enablePip) {
{ Icon(Icons.Outlined.Check, null, Modifier.size(SwitchDefaults.IconSize)) }
} else null
Switch(
checked = enablePip,
onCheckedChange = onEnablePipChange,
thumbContent = thumbContent
)
}
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (availableQualities.size > 1) {
val isBitrateMode = availableQualities.firstOrNull()?.isBitrateOnly == true
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.then(
if (isBatteryOptimized) {
Modifier.clickable(
indication = null,
interactionSource = backgroundPlayInteractionSource
) {
launcher.launch(intent)
}
} else {
Modifier
}
),
.clickable { onNavigateToQuality() },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -439,36 +472,37 @@ fun PlayerSettingsBottomSheet(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PlayCircle,
imageVector = if (isBitrateMode) Icons.Default.Speed else Icons.Default.HighQuality,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp).size(24.dp),
tint = if (isBatteryOptimized) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onSurface
}
modifier = Modifier.padding(end = 12.dp).size(24.dp)
)
Text(
text = stringResource(R.string.background_play),
style = MaterialTheme.typography.bodyLarge,
color = if (isBatteryOptimized) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onSurface
}
text = stringResource(if (isBitrateMode) R.string.bitrate else R.string.quality),
style = MaterialTheme.typography.bodyLarge
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
val displayText = if (currentQuality != null) {
"Auto (${currentQuality.label})"
} else {
stringResource(R.string.auto)
}
Text(
text = displayText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val thumbContent: (@Composable () -> Unit)? = if (backgroundPlay) {
{ Icon(Icons.Outlined.Check, null, Modifier.size(SwitchDefaults.IconSize)) }
} else null
Switch(
checked = backgroundPlay,
onCheckedChange = if (isBatteryOptimized) null else onBackgroundPlayChange,
enabled = !isBatteryOptimized,
thumbContent = thumbContent
)
}
}
if (isPipSupported) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -481,26 +515,195 @@ fun PlayerSettingsBottomSheet(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Flip,
imageVector = Icons.Default.PictureInPicture,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp).size(24.dp)
)
Text(
text = stringResource(R.string.mirror_flip),
text = stringResource(R.string.pip),
style = MaterialTheme.typography.bodyLarge
)
}
val thumbContent: (@Composable () -> Unit)? = if (mirrorFlip) {
val thumbContent: (@Composable () -> Unit)? = if (enablePip) {
{ Icon(Icons.Outlined.Check, null, Modifier.size(SwitchDefaults.IconSize)) }
} else null
Switch(
checked = mirrorFlip,
onCheckedChange = onMirrorFlipChange,
checked = enablePip,
onCheckedChange = onEnablePipChange,
thumbContent = thumbContent
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.then(
if (isBatteryOptimized) {
Modifier.clickable(
indication = null,
interactionSource = backgroundPlayInteractionSource
) {
launcher.launch(intent)
}
} else {
Modifier
}
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp).size(24.dp),
tint = if (isBatteryOptimized) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onSurface
}
)
Text(
text = stringResource(R.string.background_play),
style = MaterialTheme.typography.bodyLarge,
color = if (isBatteryOptimized) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.onSurface
}
)
}
val thumbContent: (@Composable () -> Unit)? = if (backgroundPlay) {
{ Icon(Icons.Outlined.Check, null, Modifier.size(SwitchDefaults.IconSize)) }
} else null
Switch(
checked = backgroundPlay,
onCheckedChange = if (isBatteryOptimized) null else onBackgroundPlayChange,
enabled = !isBatteryOptimized,
thumbContent = thumbContent
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Flip,
contentDescription = null,
modifier = Modifier.padding(end = 12.dp).size(24.dp)
)
Text(
text = stringResource(R.string.mirror_flip),
style = MaterialTheme.typography.bodyLarge
)
}
val thumbContent: (@Composable () -> Unit)? = if (mirrorFlip) {
{ Icon(Icons.Outlined.Check, null, Modifier.size(SwitchDefaults.IconSize)) }
} else null
Switch(
checked = mirrorFlip,
onCheckedChange = onMirrorFlipChange,
thumbContent = thumbContent
)
}
Spacer(modifier = Modifier.height(8.dp))
}
}
@Composable
private fun QualitySelectionContent(
availableQualities: List<VideoQuality>,
currentQuality: VideoQuality?,
onQualityChange: (VideoQuality?) -> Unit,
onBack: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
Text(
text = stringResource(R.string.quality),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp)
)
}
HorizontalDivider()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onQualityChange(null)
}
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val autoText = if (currentQuality != null) {
"${stringResource(R.string.auto)} (${currentQuality.label})"
} else {
stringResource(R.string.auto)
}
Text(
text = autoText,
style = MaterialTheme.typography.bodyLarge
)
if (currentQuality != null) {
Icon(
imageVector = Icons.Outlined.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
availableQualities.filter { it != currentQuality }.forEach { quality ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onQualityChange(quality)
}
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = quality.label,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
@@ -22,6 +22,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.antoniegil.astronia.player.PlaybackService
import com.antoniegil.astronia.player.QualityManager
import com.antoniegil.astronia.util.PlayerConstants
import com.antoniegil.astronia.ui.component.*
import com.antoniegil.astronia.ui.theme.LegacyThemeBackground
@@ -90,6 +91,14 @@ private fun PlayerPageContent(
LaunchedEffect(media3Player, settingsVersion) {
val decoderType = com.antoniegil.astronia.util.SettingsManager.getDecoderType(context)
media3Player.setHardwareAcceleration(decoderType == 0)
if (settingsVersion > 0 && uiState.availableQualities.isNotEmpty()) {
val qualityPref = viewModel.getQualityPreference()
val autoSelected = QualityManager.selectQualityByPreference(uiState.availableQualities, qualityPref)
if (autoSelected != null) {
viewModel.setVideoQuality(autoSelected)
}
}
}
val isFullscreen = uiState.isFullscreen
@@ -279,6 +288,7 @@ private fun PlayerPageContent(
DisposableEffect(Unit) {
onDispose {
viewModel.saveHistory(force = true)
media3Player.pause()
media3Player.attachSurface(null)
viewModel.releaseOrientationHelper()
@@ -297,6 +307,7 @@ private fun PlayerPageContent(
}
}
} else {
viewModel.saveHistory(force = true)
media3Player.pause()
onBack()
}
@@ -377,7 +388,7 @@ private fun PlayerPageContent(
}
}
} else {
viewModel.saveHistory()
viewModel.saveHistory(force = true)
onBack()
}
}
@@ -462,7 +473,10 @@ private fun PlayerPageContent(
onBackgroundPlayChange = { viewModel.updateBackgroundPlay(it) },
onAspectRatioChange = { viewModel.updateAspectRatio(it) },
onMirrorFlipChange = { viewModel.updateMirrorFlip(it) },
onDismiss = { showPlayerSettings = false }
onDismiss = { showPlayerSettings = false },
availableQualities = uiState.availableQualities,
currentQuality = uiState.currentQuality,
onQualityChange = { viewModel.setVideoQuality(it) }
)
}
}
@@ -28,8 +28,10 @@ fun VideoSettingsPage(onNavigateBack: () -> Unit) {
var decoderType by remember { mutableIntStateOf(SettingsManager.getDecoderType(context)) }
var aspectRatio by remember { mutableIntStateOf(SettingsManager.getAspectRatio(context)) }
var mirrorFlip by remember { mutableStateOf(SettingsManager.getMirrorFlip(context)) }
var qualityPreference by remember { mutableIntStateOf(SettingsManager.getQualityPreference(context)) }
var showAspectRatioDialog by remember { mutableStateOf(false) }
var showQualityPreferenceDialog by remember { mutableStateOf(false) }
var showDecoderTypeDialog by remember { mutableStateOf(false) }
Scaffold(
@@ -65,6 +67,19 @@ fun VideoSettingsPage(onNavigateBack: () -> Unit) {
)
}
item {
PreferenceItem(
title = stringResource(R.string.quality_preference),
description = when(qualityPreference) {
0 -> stringResource(R.string.quality_preference_default)
1 -> stringResource(R.string.quality_preference_saver)
else -> stringResource(R.string.quality_preference_default)
},
icon = Icons.Outlined.HighQuality,
onClick = { showQualityPreferenceDialog = true }
)
}
item {
PreferenceSwitch(
title = stringResource(R.string.mirror_flip),
@@ -114,6 +129,22 @@ fun VideoSettingsPage(onNavigateBack: () -> Unit) {
)
}
if (showQualityPreferenceDialog) {
RadioButtonDialog(
title = stringResource(R.string.quality_preference),
options = listOf(
0 to stringResource(R.string.quality_preference_default),
1 to stringResource(R.string.quality_preference_saver)
),
selectedValue = qualityPreference,
onDismiss = { showQualityPreferenceDialog = false },
onSelect = { value ->
qualityPreference = value
SettingsManager.setQualityPreference(context, value)
}
)
}
if (showDecoderTypeDialog) {
RadioButtonDialog(
title = stringResource(R.string.decoder),
@@ -58,6 +58,9 @@ object SettingsManager {
fun getMirrorFlip(context: Context): Boolean = getInstance(context).getMirrorFlip()
fun setMirrorFlip(context: Context, value: Boolean) = getInstance(context).setMirrorFlip(value)
fun getQualityPreference(context: Context): Int = getInstance(context).getQualityPreference()
fun setQualityPreference(context: Context, value: Int) = getInstance(context).setQualityPreference(value)
fun getThemeMode(context: Context): Int = getInstance(context).getThemeMode()
fun setThemeMode(context: Context, value: Int) = getInstance(context).setThemeMode(value)
@@ -201,6 +204,9 @@ internal class PreferenceManagerImpl(context: Context) {
fun getMirrorFlip(): Boolean = getBoolean(KEY_MIRROR_FLIP, false)
fun setMirrorFlip(value: Boolean) = putBoolean(KEY_MIRROR_FLIP, value)
fun getQualityPreference(): Int = getInt(KEY_QUALITY_PREFERENCE, 0)
fun setQualityPreference(value: Int) = putInt(KEY_QUALITY_PREFERENCE, value)
fun getThemeMode(): Int = getInt(KEY_THEME_MODE, 0)
fun setThemeMode(value: Int) = updateThemeSettings { it.copy(themeMode = value) }
@@ -336,6 +342,7 @@ internal class PreferenceManagerImpl(context: Context) {
private const val KEY_ASPECT_RATIO = "aspect_ratio"
private const val KEY_DECODER_TYPE = "decoder_type"
private const val KEY_MIRROR_FLIP = "mirror_flip"
private const val KEY_QUALITY_PREFERENCE = "quality_preference"
private const val KEY_THEME_MODE = "theme_mode"
private const val KEY_LANGUAGE_TAG = "language_tag"
private const val KEY_DYNAMIC_COLOR = "dynamic_color"
@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
import com.antoniegil.astronia.Astronia
import com.antoniegil.astronia.data.repository.PlayerRepository
import com.antoniegil.astronia.player.Media3Player
import com.antoniegil.astronia.player.QualityManager
import com.antoniegil.astronia.player.VideoQuality
import com.antoniegil.astronia.util.ErrorHandler
import com.antoniegil.astronia.util.HistoryManager
import com.antoniegil.astronia.util.M3U8Channel
@@ -41,7 +43,9 @@ data class PlayerUiState(
val autoHideControls: Boolean = true,
val playlistUrl: String = "",
val shouldScrollToChannel: Boolean = false,
val isFullscreen: Boolean = false
val isFullscreen: Boolean = false,
val availableQualities: List<VideoQuality> = emptyList(),
val currentQuality: VideoQuality? = null
)
data class PlayerProgressState(
@@ -62,10 +66,12 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val progressState: StateFlow<PlayerProgressState> = _progressState.asStateFlow()
private var localPlayer: Media3Player? = null
private var globalPlayer: Media3Player? = null
private var isUsingGlobalPlayer = false
private var lastSurface: Surface? = null
private var isBackgroundRetained = false
private var orientationHelper: OrientationHelper? = null
private var lastAppliedQualityId: String? = null
init {
loadPreferences()
@@ -83,6 +89,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun refreshPreferences() {
loadPreferences()
lastAppliedQualityId = null
}
fun loadChannels(url: String, initialChannelUrl: String?, initialChannelId: String?, initialVideoTitle: String?) {
@@ -132,7 +139,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
url.startsWith("rtmp") || url.startsWith("rtsp") -> {
repository.parseM3U8FromUrl(url).onSuccess { channels ->
parsedChannels = channels
isM3U8Playlist = channels.size >= 1
isM3U8Playlist = channels.isNotEmpty()
}.onError { _, _ ->
}
}
@@ -144,7 +151,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
if (content.contains("#EXTM3U")) {
repository.parseM3U8FromContent(content).onSuccess { channels ->
parsedChannels = channels
isM3U8Playlist = channels.size >= 1
isM3U8Playlist = channels.isNotEmpty()
}
}
}
@@ -154,7 +161,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
url.contains("#EXTM3U") -> {
repository.parseM3U8FromContent(url).onSuccess { channels ->
parsedChannels = channels
isM3U8Playlist = channels.size >= 1
isM3U8Playlist = channels.isNotEmpty()
}
}
}
@@ -264,6 +271,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
return
}
val wasPlaying = _uiState.value.isPlaying
lastAppliedQualityId = null
_uiState.value = _uiState.value.copy(
currentChannelUrl = channel.url,
currentChannelId = channel.id,
@@ -274,7 +282,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
isPlaying = wasPlaying,
isBuffering = false,
shouldScrollToChannel = false,
actualPlayingUrl = null
actualPlayingUrl = null,
availableQualities = emptyList(),
currentQuality = null
)
_progressState.value = PlayerProgressState(currentCycleDuration = 20f)
watchTimeTracker.reset()
@@ -287,12 +297,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun getOrCreatePlayer(backgroundPlay: Boolean): Media3Player {
val player = if (backgroundPlay) {
isUsingGlobalPlayer = true
Astronia.getOrCreatePlayer(getApplication<Application>().applicationContext as Astronia)
val p = Astronia.getOrCreatePlayer(getApplication<Application>().applicationContext as Astronia)
globalPlayer = p
p
} else {
if (localPlayer == null) {
isUsingGlobalPlayer = false
localPlayer = Media3Player(getApplication())
}
globalPlayer = null
localPlayer!!
}
@@ -316,6 +329,30 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
).show()
}
}
onTracksChangedListener = { tracks ->
exoPlayer?.let { exo ->
val (qualities, selected) = QualityManager.parseQualities(tracks, exo.trackSelectionParameters)
val finalQuality = if (selected == null && qualities.isNotEmpty()) {
val qualityPref = repository.getQualityPreference()
val autoSelected = QualityManager.selectQualityByPreference(qualities, qualityPref)
if (autoSelected != null) {
QualityManager.setQuality(exo, autoSelected)
lastAppliedQualityId = autoSelected.id
autoSelected
} else {
null
}
} else {
selected
}
_uiState.value = _uiState.value.copy(
availableQualities = qualities,
currentQuality = finalQuality
)
}
}
}
return player
@@ -356,11 +393,24 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
repository.setMirrorFlip(value)
_uiState.value = _uiState.value.copy(mirrorFlip = value)
}
fun setVideoQuality(quality: VideoQuality?) {
val player = localPlayer ?: globalPlayer
player?.let { p ->
QualityManager.setQuality(p.exoPlayer, quality)
}
}
fun saveHistory() {
fun getQualityPreference(): Int = repository.getQualityPreference()
fun updateCurrentQuality(quality: VideoQuality?) {
_uiState.value = _uiState.value.copy(currentQuality = quality)
}
fun saveHistory(force: Boolean = false) {
val state = _uiState.value
val watchTime = watchTimeTracker.getAccumulatedTime()
if (watchTime == 0L) {
if (!force && watchTime == 0L) {
return
}
val urlToSave = state.playlistUrl.ifEmpty { state.currentChannelUrl }
@@ -37,6 +37,9 @@
<string name="playlist">播放列表</string>
<string name="channel_count">频道列表 (%d)</string>
<string name="search_channels">搜索频道</string>
<string name="quality">画质</string>
<string name="auto">自动</string>
<string name="bitrate">比特率</string>
<string name="resolution">分辨率</string>
<string name="frames">帧数(丢弃 / 总数)</string>
@@ -90,6 +93,9 @@
<string name="aspect_ratio">画面比例</string>
<string name="aspect_ratio_fill">填充</string>
<string name="aspect_ratio_original">原始</string>
<string name="quality_preference">画质偏好</string>
<string name="quality_preference_default">默认(最高)</string>
<string name="quality_preference_saver">省流</string>
<string name="decoder">解码器</string>
<string name="decoder_hardware">硬解</string>
<string name="decoder_software">软解</string>
@@ -37,6 +37,9 @@
<string name="playlist">播放列表</string>
<string name="channel_count">頻道列表 (%d)</string>
<string name="search_channels">搵頻道</string>
<string name="quality">畫質</string>
<string name="auto">自動</string>
<string name="bitrate">位元率</string>
<string name="resolution">解像度</string>
<string name="frames">影格數(跌格 / 總數)</string>
@@ -90,6 +93,9 @@
<string name="aspect_ratio">畫面比例</string>
<string name="aspect_ratio_fill">填滿</string>
<string name="aspect_ratio_original">原始</string>
<string name="quality_preference">畫質偏好</string>
<string name="quality_preference_default">預設(最高)</string>
<string name="quality_preference_saver">省流</string>
<string name="decoder">解碼器</string>
<string name="decoder_hardware">硬解</string>
<string name="decoder_software">軟解</string>
@@ -37,6 +37,9 @@
<string name="playlist">播放列表</string>
<string name="channel_count">頻道列表 (%d)</string>
<string name="search_channels">搜尋頻道</string>
<string name="quality">畫質</string>
<string name="auto">自動</string>
<string name="bitrate">位元率</string>
<string name="resolution">解析度</string>
<string name="frames">影格數(掉幀 / 總數)</string>
@@ -90,6 +93,9 @@
<string name="aspect_ratio">畫面比例</string>
<string name="aspect_ratio_fill">填滿</string>
<string name="aspect_ratio_original">原始</string>
<string name="quality_preference">畫質偏好</string>
<string name="quality_preference_default">預設(最高)</string>
<string name="quality_preference_saver">省流</string>
<string name="decoder">解碼器</string>
<string name="decoder_hardware">硬解</string>
<string name="decoder_software">軟解</string>
+7 -1
View File
@@ -39,7 +39,10 @@
<string name="playlist">Playlist</string>
<string name="channel_count">Channel List (%d)</string>
<string name="search_channels">Search channels</string>
<string name="quality">Quality</string>
<string name="auto">Auto</string>
<string name="bitrate">Bitrate</string>
<string name="resolution">Resolution</string>
<string name="frames">Frames (Dropped / Total)</string>
<string name="codec">Codec</string>
@@ -93,6 +96,9 @@
<string name="aspect_ratio">Aspect Ratio</string>
<string name="aspect_ratio_fill">Fill</string>
<string name="aspect_ratio_original">Original</string>
<string name="quality_preference">Quality Preference</string>
<string name="quality_preference_default">Default (Highest)</string>
<string name="quality_preference_saver">Data Saver</string>
<string name="decoder">Decoder</string>
<string name="decoder_hardware">Hardware</string>
<string name="decoder_software">Software</string>