feat: add quality selection and preference settings
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user