mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-10-26 16:45:26 +00:00
Audio selection and initial cleanup
This commit is contained in:
16 files changed
+200
-895
No files matched your search
@@ -55,7 +55,11 @@ class TrackSelectionHelper(
|
||||
* @see selectPlayerAudioTrack
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private fun selectPlayerAudioTrack(mediaSource: JellyfinMediaSource, audioStream: MediaStream, initial: Boolean): Boolean {
|
||||
private fun selectPlayerAudioTrack(
|
||||
mediaSource: JellyfinMediaSource,
|
||||
audioStream: MediaStream,
|
||||
initial: Boolean,
|
||||
): Boolean {
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
|
||||
// Transcoding does not require explicit audio selection
|
||||
return true
|
||||
@@ -113,7 +117,11 @@ class TrackSelectionHelper(
|
||||
* @see selectSubtitleTrack
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private fun selectSubtitleTrack(mediaSource: JellyfinMediaSource, subtitleStream: MediaStream?, initial: Boolean): Boolean {
|
||||
private fun selectSubtitleTrack(
|
||||
mediaSource: JellyfinMediaSource,
|
||||
subtitleStream: MediaStream?,
|
||||
initial: Boolean,
|
||||
): Boolean {
|
||||
when {
|
||||
// Fast-pass: Skip execution on subsequent calls with the same selection
|
||||
!initial && subtitleStream === mediaSource.selectedSubtitleStream -> return true
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -11,6 +13,7 @@ const val ShowControlsAnimationDuration = 60
|
||||
const val HideControlsAnimationDuration = 120
|
||||
val DefaultThumbSize = 8.dp
|
||||
val DraggedThumbSize = 12.dp
|
||||
val PlaybackSpeedOptions = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f)
|
||||
|
||||
// Player gestures
|
||||
const val DoubleTapRippleDurationMs = 100L
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.R
|
||||
@@ -35,12 +34,7 @@ import org.jellyfin.mobile.player.PlayerException
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.player.ui.components.PlayerScreen
|
||||
import org.jellyfin.mobile.player.ui.config.DecoderType
|
||||
import org.jellyfin.mobile.player.ui.event.UiEvent
|
||||
import org.jellyfin.mobile.player.ui.legacy.PlayerGestureHelper
|
||||
import org.jellyfin.mobile.player.ui.legacy.PlayerLockScreenHelper
|
||||
import org.jellyfin.mobile.player.ui.legacy.PlayerMenus
|
||||
import org.jellyfin.mobile.player.ui.legacy.TrackSelectionCallback
|
||||
import org.jellyfin.mobile.player.ui.utils.FullscreenHelper
|
||||
import org.jellyfin.mobile.player.ui.utils.SwipeGestureHelper
|
||||
import org.jellyfin.mobile.ui.utils.AppTheme
|
||||
@@ -69,14 +63,9 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
private var _viewBinding: FragmentComposeBinding? = null
|
||||
private val viewBinding get() = _viewBinding!!
|
||||
private val composeView: ComposeView get() = viewBinding.composeView
|
||||
|
||||
private var playerLocation = Rect()
|
||||
|
||||
private var playerMenus: PlayerMenus? = null
|
||||
|
||||
private lateinit var fullscreenHelper: FullscreenHelper
|
||||
lateinit var playerLockScreenHelper: PlayerLockScreenHelper
|
||||
lateinit var playerGestureHelper: PlayerGestureHelper
|
||||
|
||||
private val currentVideoStream: MediaStream?
|
||||
get() = viewModel.mediaSourceOrNull?.selectedVideoStream
|
||||
@@ -103,10 +92,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
}
|
||||
}
|
||||
viewModel.playerState.observe(this) {
|
||||
requireActivity().window.keepScreenOn = viewModel.playerOrNull?.isPlaying == true
|
||||
}
|
||||
viewModel.decoderType.observe(this) { type ->
|
||||
playerMenus?.updatedSelectedDecoder(type)
|
||||
requireActivity().window.keepScreenOn = uiViewModel.shouldShowPauseButton
|
||||
}
|
||||
viewModel.error.observe(this) { message ->
|
||||
val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) }
|
||||
@@ -122,9 +108,6 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
// Auto-switch to landscape for landscape videos if enabled
|
||||
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
|
||||
// Update title and player menus
|
||||
playerMenus?.onQueueItemChanged(mediaSource, viewModel.queueManager.hasNext())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,7 +231,7 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
* Exit fullscreen on first back-button press, otherwise exit directly
|
||||
*/
|
||||
override fun onInterceptBackPressed(): Boolean = when {
|
||||
playerFullscreenHelper.isFullscreen -> {
|
||||
fullscreenHelper.isFullscreen -> {
|
||||
// TODO: exit fullscreen
|
||||
true
|
||||
}
|
||||
@@ -276,99 +259,9 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fullscreen.
|
||||
*
|
||||
* If playing a portrait video, this just hides the status and navigation bars.
|
||||
* For landscape videos, additionally the screen gets rotated.
|
||||
*/
|
||||
private fun toggleFullscreen() {
|
||||
val videoTrack = currentVideoStream
|
||||
if (videoTrack == null || videoTrack.width!! >= videoTrack.height!!) {
|
||||
val current = resources.configuration.orientation
|
||||
requireActivity().requestedOrientation = when (current) {
|
||||
Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// No need to call playerFullscreenHelper in this case,
|
||||
// since the configuration change triggers updateFullscreenState,
|
||||
// which does it for us.
|
||||
} else {
|
||||
playerFullscreenHelper.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, the player controls will show indefinitely
|
||||
*/
|
||||
fun suppressControllerAutoHide(suppress: Boolean) {
|
||||
//playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS
|
||||
}
|
||||
|
||||
fun isLandscape(configuration: Configuration = resources.configuration) =
|
||||
private fun isLandscape(configuration: Configuration = resources.configuration) =
|
||||
configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
fun onRewind() = viewModel.rewind()
|
||||
|
||||
fun onFastForward() = viewModel.fastForward()
|
||||
|
||||
/**
|
||||
* @param callback called if track selection was successful and UI needs to be updated
|
||||
*/
|
||||
fun onAudioTrackSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
|
||||
if (viewModel.trackSelectionHelper.selectAudioTrack(index)) {
|
||||
callback.onTrackSelected(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callback called if track selection was successful and UI needs to be updated
|
||||
*/
|
||||
fun onSubtitleSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
|
||||
if (viewModel.trackSelectionHelper.selectSubtitleTrack(index)) {
|
||||
callback.onTrackSelected(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple.
|
||||
*
|
||||
* @return true if subtitles are enabled now, false if not
|
||||
*/
|
||||
fun toggleSubtitles(callback: TrackSelectionCallback) = lifecycleScope.launch {
|
||||
callback.onTrackSelected(viewModel.trackSelectionHelper.toggleSubtitles())
|
||||
}
|
||||
|
||||
fun onBitrateChanged(bitrate: Int?, callback: TrackSelectionCallback) = lifecycleScope.launch {
|
||||
viewModel.changeBitrate(bitrate)
|
||||
callback.onTrackSelected(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the playback speed was changed
|
||||
*/
|
||||
fun onSpeedSelected(speed: Float): Boolean {
|
||||
return viewModel.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
fun onDecoderSelected(type: DecoderType) {
|
||||
viewModel.updateDecoderType(type)
|
||||
}
|
||||
|
||||
fun onSkipToPrevious() {
|
||||
viewModel.skipToPrevious()
|
||||
}
|
||||
|
||||
fun onSkipToNext() {
|
||||
viewModel.skipToNext()
|
||||
}
|
||||
|
||||
fun onPopupDismissed() {
|
||||
if (!AndroidVersion.isAtLeastR) {
|
||||
updateFullscreenState(resources.configuration)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
if (AndroidVersion.isAtLeastN && viewModel.playerOrNull?.isPlaying == true) {
|
||||
requireActivity().enterPictureInPicture()
|
||||
@@ -400,17 +293,12 @@ class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
uiViewModel.onPictureInPictureModeChanged(isInPictureInPictureMode)
|
||||
if (isInPictureInPictureMode) {
|
||||
playerMenus?.dismissPlaybackInfo()
|
||||
//playerLockScreenHelper.hideUnlockButton()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
updateFullscreenState(newConfig)
|
||||
//playerGestureHelper.handleConfiguration(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,27 +8,17 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.utils.PlaybackInfoBuilder
|
||||
import org.jellyfin.mobile.ui.utils.PlaybackInfoBackgroundColor
|
||||
import org.jellyfin.mobile.ui.utils.PlaybackInfoTextStyle
|
||||
import org.jellyfin.mobile.ui.utils.PlayerBackInfoBackgroundShape
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Composable
|
||||
fun PlaybackInfo(
|
||||
mediaSource: JellyfinMediaSource?,
|
||||
playbackInfo: String,
|
||||
onClose: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
playbackInfoBuilder: PlaybackInfoBuilder = koinInject(),
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val playbackInfo = remember(mediaSource) {
|
||||
mediaSource?.let { playbackInfoBuilder.buildPlaybackInfo(resources, mediaSource) }.orEmpty()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = playbackInfo,
|
||||
modifier = modifier
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -42,6 +41,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -50,17 +50,20 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.android.exoplayer2.Player
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.HideControlsAnimationDuration
|
||||
import org.jellyfin.mobile.player.ui.PlayerUiViewModel
|
||||
import org.jellyfin.mobile.player.ui.ShowControlsAnimationDuration
|
||||
import org.jellyfin.mobile.player.ui.components.controls.PlayerControls
|
||||
import org.jellyfin.mobile.player.ui.components.controls.PlayerPosition
|
||||
import org.jellyfin.mobile.player.ui.config.GestureIndicatorState
|
||||
import org.jellyfin.mobile.player.ui.utils.PlaybackInfoBuilder
|
||||
import org.jellyfin.mobile.ui.utils.PlaybackInfoBackgroundColor
|
||||
import org.jellyfin.mobile.utils.isBuffering
|
||||
import org.jellyfin.mobile.utils.shouldShowNextButton
|
||||
import org.jellyfin.mobile.utils.shouldShowPauseButton
|
||||
import org.jellyfin.mobile.utils.shouldShowPreviousButton
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Stable
|
||||
val DefaultControlsEnterTransition = fadeIn(
|
||||
@@ -82,18 +85,29 @@ val DefaultControlsExitTransition = fadeOut(
|
||||
@Composable
|
||||
fun PlayerOverlay(
|
||||
player: Player,
|
||||
mediaSource: JellyfinMediaSource?,
|
||||
gestureIndicatorState: GestureIndicatorState,
|
||||
viewModel: PlayerViewModel = viewModel(),
|
||||
uiViewModel: PlayerUiViewModel = viewModel(),
|
||||
playbackInfoBuilder: PlaybackInfoBuilder = koinInject(),
|
||||
) {
|
||||
val mediaSource by viewModel.queueManager.currentMediaSource.collectAsState()
|
||||
val resources = LocalContext.current.resources
|
||||
var shouldShowInfo by remember { mutableStateOf(false) }
|
||||
var shouldShowLoadingIndicator by remember { mutableStateOf(player.isBuffering) }
|
||||
var shouldShowPreviousButton by remember { mutableStateOf(player.shouldShowPreviousButton) }
|
||||
var shouldShowNextButton by remember { mutableStateOf(player.shouldShowNextButton) }
|
||||
var playerPosition by remember { mutableStateOf(player.position) }
|
||||
var duration by remember { mutableLongStateOf(player.duration) }
|
||||
val audioStreams = remember(mediaSource, mediaSource?.selectedAudioStream) {
|
||||
mediaSource?.let { playbackInfoBuilder.buildAudioStreams(mediaSource) }.orEmpty()
|
||||
}
|
||||
var playbackSpeed by remember { mutableFloatStateOf(player.playbackParameters.speed) }
|
||||
val qualityOptions = remember(mediaSource) {
|
||||
mediaSource?.let { playbackInfoBuilder.buildQualityOptions(resources, mediaSource) }.orEmpty()
|
||||
}
|
||||
val playbackInfo = remember(mediaSource) {
|
||||
mediaSource?.let { playbackInfoBuilder.buildPlaybackInfo(resources, mediaSource) }.orEmpty()
|
||||
}
|
||||
|
||||
PlayerEventsHandler(
|
||||
player = player,
|
||||
@@ -140,7 +154,9 @@ fun PlayerOverlay(
|
||||
shouldShowNextButton = shouldShowNextButton,
|
||||
playerPosition = playerPosition,
|
||||
duration = duration,
|
||||
audioTracks = audioStreams,
|
||||
playbackSpeed = playbackSpeed,
|
||||
qualityOptions = qualityOptions,
|
||||
onSuppressControlsTimeoutChanged = { isSuppressed ->
|
||||
uiViewModel.onSuppressControlsTimeoutChanged(isSuppressed)
|
||||
},
|
||||
@@ -177,7 +193,7 @@ fun PlayerOverlay(
|
||||
exit = DefaultControlsExitTransition,
|
||||
) {
|
||||
PlaybackInfo(
|
||||
mediaSource = mediaSource,
|
||||
playbackInfo = playbackInfo,
|
||||
onClose = {
|
||||
shouldShowInfo = false
|
||||
},
|
||||
|
||||
@@ -61,8 +61,9 @@ fun PlayerScreen(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val isLandscape by rememberUpdatedState(LocalConfiguration.current.isLandscape)
|
||||
val player by viewModel.player.collectAsState()
|
||||
val rippleInteractionSource = remember { MutableInteractionSource() }
|
||||
val mediaSource by viewModel.queueManager.currentMediaSource.collectAsState()
|
||||
var contentSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val rippleInteractionSource = remember { MutableInteractionSource() }
|
||||
var gestureIndicatorState by remember { mutableStateOf<GestureIndicatorState>(GestureIndicatorState.Hidden) }
|
||||
val gestureExclusionSizePx by rememberUpdatedState(
|
||||
with(LocalDensity.current) { SwipeGestureExclusionSizeVertical.toPx() },
|
||||
@@ -203,6 +204,7 @@ fun PlayerScreen(
|
||||
) {
|
||||
PlayerOverlay(
|
||||
player = player,
|
||||
mediaSource = mediaSource,
|
||||
gestureIndicatorState = gestureIndicatorState,
|
||||
viewModel = viewModel,
|
||||
uiViewModel = uiViewModel,
|
||||
|
||||
+16
-3
@@ -15,14 +15,18 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.android.exoplayer2.Player
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.PlayerUiViewModel
|
||||
import org.jellyfin.mobile.player.ui.config.DecoderType
|
||||
import org.jellyfin.mobile.player.ui.config.UiAudioTrack
|
||||
import org.jellyfin.mobile.player.ui.config.UiQualityOption
|
||||
import org.jellyfin.mobile.player.ui.event.UiEvent
|
||||
import org.jellyfin.mobile.ui.utils.PlayerControlsBackgroundColor
|
||||
import org.jellyfin.mobile.utils.dispatchPlayPause
|
||||
@@ -39,7 +43,9 @@ fun PlayerControls(
|
||||
shouldShowNextButton: Boolean,
|
||||
playerPosition: PlayerPosition,
|
||||
duration: Long,
|
||||
audioTracks: List<UiAudioTrack>,
|
||||
playbackSpeed: Float,
|
||||
qualityOptions: List<UiQualityOption>,
|
||||
onSuppressControlsTimeoutChanged: (isSuppressed: Boolean) -> Unit,
|
||||
onLockControls: () -> Unit,
|
||||
onToggleInfo: () -> Unit,
|
||||
@@ -86,19 +92,26 @@ fun PlayerControls(
|
||||
)
|
||||
},
|
||||
options = {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
PlayerOptions(
|
||||
mediaSource = mediaSource,
|
||||
audioTracks = audioTracks,
|
||||
subtitleState = SubtitleControlsState(
|
||||
subtitleStreams = emptyList(),
|
||||
selectedSubtitle = null,
|
||||
),
|
||||
playbackSpeed = playbackSpeed,
|
||||
qualityOptions = qualityOptions,
|
||||
decoder = decoder,
|
||||
isInFullscreen = @OptIn(ExperimentalLayoutApi::class) !WindowInsets.areStatusBarsVisible,
|
||||
onSuppressControlsTimeoutChanged = onSuppressControlsTimeoutChanged,
|
||||
onLockControls = onLockControls,
|
||||
onShowAudioTracks = { /*TODO*/ },
|
||||
onSubtitleSelected = { /*TODO*/ },
|
||||
onAudioTrackSelected = { index ->
|
||||
coroutineScope.launch {
|
||||
viewModel.trackSelectionHelper.selectAudioTrack(index)
|
||||
}
|
||||
},
|
||||
onSubtitleTrackSelected = { /*TODO*/ },
|
||||
onSpeedSelected = { speed ->
|
||||
viewModel.setPlaybackSpeed(speed)
|
||||
},
|
||||
|
||||
+100
-94
@@ -1,7 +1,6 @@
|
||||
package org.jellyfin.mobile.player.ui.components.controls
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
@@ -11,10 +10,8 @@ import androidx.compose.foundation.layout.requiredWidthIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.RadioButton
|
||||
import androidx.compose.material.RadioButtonDefaults
|
||||
@@ -33,34 +30,35 @@ import androidx.compose.material.icons.outlined.VideoSettings
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.PlaybackSpeedOptions
|
||||
import org.jellyfin.mobile.player.ui.config.DecoderType
|
||||
import org.jellyfin.mobile.player.ui.utils.PlaybackInfoBuilder
|
||||
import org.jellyfin.mobile.player.ui.config.UiAudioTrack
|
||||
import org.jellyfin.mobile.player.ui.config.UiQualityOption
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@Suppress("LongParameterList", "LongMethod")
|
||||
@Composable
|
||||
fun PlayerOptions(
|
||||
mediaSource: JellyfinMediaSource?,
|
||||
audioTracks: List<UiAudioTrack>,
|
||||
subtitleState: SubtitleControlsState,
|
||||
playbackSpeed: Float,
|
||||
qualityOptions: List<UiQualityOption>,
|
||||
decoder: DecoderType,
|
||||
isInFullscreen: Boolean,
|
||||
onSuppressControlsTimeoutChanged: (isSuppressed: Boolean) -> Unit,
|
||||
onLockControls: () -> Unit,
|
||||
onShowAudioTracks: () -> Unit,
|
||||
onSubtitleSelected: (MediaStream) -> Unit,
|
||||
onAudioTrackSelected: (index: Int) -> Unit,
|
||||
onSubtitleTrackSelected: (index: Int) -> Unit,
|
||||
onSpeedSelected: (speed: Float) -> Unit,
|
||||
onBitrateSelected: (bitrate: Int?) -> Unit,
|
||||
onDecoderSelected: (decoder: DecoderType) -> Unit,
|
||||
@@ -77,54 +75,44 @@ fun PlayerOptions(
|
||||
contentDescription = R.string.player_controls_lock_controls_description,
|
||||
onClick = onLockControls,
|
||||
)
|
||||
PlayerOptionButton(
|
||||
icon = Icons.Outlined.MusicNote,
|
||||
contentDescription = R.string.player_controls_audio_track_selection_description,
|
||||
onClick = onShowAudioTracks,
|
||||
AudioStreamsMenu(
|
||||
audioTracks = audioTracks,
|
||||
onMenuVisibilityChanged = onSuppressControlsTimeoutChanged,
|
||||
onAudioTrackSelected = onAudioTrackSelected,
|
||||
)
|
||||
Popup {
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
for (subtitleStream in subtitleState.subtitleStreams) {
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
onSubtitleSelected(subtitleStream)
|
||||
},
|
||||
) {
|
||||
Text(text = subtitleStream.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerOptionButton(
|
||||
icon = if (subtitleState.areSubtitlesEnabled) Icons.Outlined.Subtitles else Icons.Outlined.SubtitlesOff,
|
||||
contentDescription = when {
|
||||
subtitleState.isInToggleSubtitlesMode -> R.string.player_controls_toggle_subtitles_description
|
||||
else -> R.string.player_controls_subtitle_selection_description
|
||||
},
|
||||
enabled = subtitleState.hasSubtitles,
|
||||
onClick = {
|
||||
if (subtitleState.isInToggleSubtitlesMode) {
|
||||
/*fragment.toggleSubtitles { enabled ->
|
||||
if (subtitleState.isInToggleSubtitlesMode) {
|
||||
PlayerOptionButton(
|
||||
icon = if (subtitleState.areSubtitlesEnabled) Icons.Outlined.Subtitles else Icons.Outlined.SubtitlesOff,
|
||||
contentDescription = when {
|
||||
subtitleState.isInToggleSubtitlesMode -> R.string.player_controls_toggle_subtitles_description
|
||||
else -> R.string.player_controls_subtitle_selection_description
|
||||
},
|
||||
enabled = subtitleState.hasSubtitles,
|
||||
onClick = {
|
||||
|
||||
|
||||
if (subtitleState.isInToggleSubtitlesMode) {
|
||||
/*fragment.toggleSubtitles { enabled ->
|
||||
subtitlesEnabled = enabled
|
||||
updateSubtitlesButton()
|
||||
}*/
|
||||
} else {
|
||||
/*fragment.suppressControllerAutoHide(true)
|
||||
} else {
|
||||
/*fragment.suppressControllerAutoHide(true)
|
||||
subtitlesMenu.show()*/
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
PlaybackSpeedMenu(
|
||||
playbackSpeed = playbackSpeed,
|
||||
currentSpeed = playbackSpeed,
|
||||
onMenuVisibilityChanged = onSuppressControlsTimeoutChanged,
|
||||
onSpeedSelected = onSpeedSelected,
|
||||
)
|
||||
if (mediaSource != null) {
|
||||
QualityOptionsMenu(
|
||||
jellyfinMediaSource = mediaSource,
|
||||
onMenuVisibilityChanged = onSuppressControlsTimeoutChanged,
|
||||
onBitrateSelected = onBitrateSelected,
|
||||
)
|
||||
}
|
||||
QualityOptionsMenu(
|
||||
qualityOptions = qualityOptions,
|
||||
onMenuVisibilityChanged = onSuppressControlsTimeoutChanged,
|
||||
onBitrateSelected = onBitrateSelected,
|
||||
)
|
||||
DecoderOptionsMenu(
|
||||
currentDecoder = decoder,
|
||||
onMenuVisibilityChanged = onSuppressControlsTimeoutChanged,
|
||||
@@ -150,22 +138,57 @@ fun PlayerOptions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioStreamsMenu(
|
||||
audioTracks: List<UiAudioTrack>,
|
||||
onMenuVisibilityChanged: (isVisible: Boolean) -> Unit,
|
||||
onAudioTrackSelected: (index: Int) -> Unit,
|
||||
) {
|
||||
PlayerOptionMenu(
|
||||
icon = Icons.Outlined.MusicNote,
|
||||
contentDescription = R.string.player_controls_audio_track_selection_description,
|
||||
items = audioTracks,
|
||||
onMenuVisibilityChanged = onMenuVisibilityChanged,
|
||||
onItemSelected = { track ->
|
||||
onAudioTrackSelected(track.index)
|
||||
},
|
||||
) { track ->
|
||||
Text(text = track.label)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.requiredWidthIn(min = 16.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
|
||||
RadioButton(
|
||||
selected = track.isSelected,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubtitleMenu(
|
||||
mediaSource: JellyfinMediaSource,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackSpeedMenu(
|
||||
playbackSpeed: Float,
|
||||
onMenuVisibilityChanged: (visible: Boolean) -> Unit,
|
||||
currentSpeed: Float,
|
||||
onMenuVisibilityChanged: (isVisible: Boolean) -> Unit,
|
||||
onSpeedSelected: (Float) -> Unit,
|
||||
) {
|
||||
val speedOptions = remember { listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f) }
|
||||
|
||||
PlayerOptionMenu(
|
||||
icon = Icons.Outlined.SlowMotionVideo,
|
||||
contentDescription = R.string.player_controls_playback_speed_description,
|
||||
items = speedOptions,
|
||||
selectedItem = playbackSpeed,
|
||||
items = PlaybackSpeedOptions,
|
||||
onMenuVisibilityChanged = onMenuVisibilityChanged,
|
||||
onItemSelected = onSpeedSelected,
|
||||
) { speed, isSelected ->
|
||||
) { speed ->
|
||||
Text(text = remember(speed) { "%.2fx".format(speed) })
|
||||
|
||||
Spacer(
|
||||
@@ -175,7 +198,7 @@ private fun PlaybackSpeedMenu(
|
||||
)
|
||||
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
selected = speed == currentSpeed,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary),
|
||||
)
|
||||
@@ -184,34 +207,19 @@ private fun PlaybackSpeedMenu(
|
||||
|
||||
@Composable
|
||||
private fun QualityOptionsMenu(
|
||||
jellyfinMediaSource: JellyfinMediaSource,
|
||||
onMenuVisibilityChanged: (visible: Boolean) -> Unit,
|
||||
qualityOptions: List<UiQualityOption>,
|
||||
onMenuVisibilityChanged: (isVisible: Boolean) -> Unit,
|
||||
onBitrateSelected: (Int?) -> Unit,
|
||||
playbackInfoBuilder: PlaybackInfoBuilder = koinInject(),
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val qualityOptions = remember(jellyfinMediaSource) {
|
||||
playbackInfoBuilder.buildQualityOptions(
|
||||
resources = resources,
|
||||
jellyfinMediaSource = jellyfinMediaSource,
|
||||
)
|
||||
}
|
||||
val selectedOption = remember(jellyfinMediaSource, qualityOptions) {
|
||||
qualityOptions.find { option ->
|
||||
option.bitrate == jellyfinMediaSource.maxStreamingBitrate
|
||||
} ?: qualityOptions.last() // last is "auto"
|
||||
}
|
||||
|
||||
PlayerOptionMenu(
|
||||
icon = Icons.Outlined.Settings,
|
||||
contentDescription = R.string.player_controls_bitrate_selection_description,
|
||||
items = qualityOptions,
|
||||
selectedItem = selectedOption,
|
||||
onMenuVisibilityChanged = onMenuVisibilityChanged,
|
||||
onItemSelected = { option ->
|
||||
onBitrateSelected(option.bitrate)
|
||||
},
|
||||
itemContent = { option, isSelected ->
|
||||
itemContent = { option ->
|
||||
Text(text = option.label)
|
||||
|
||||
Spacer(
|
||||
@@ -221,7 +229,7 @@ private fun QualityOptionsMenu(
|
||||
)
|
||||
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
selected = option.isSelected,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary),
|
||||
)
|
||||
@@ -232,22 +240,19 @@ private fun QualityOptionsMenu(
|
||||
@Composable
|
||||
private fun DecoderOptionsMenu(
|
||||
currentDecoder: DecoderType,
|
||||
onMenuVisibilityChanged: (visible: Boolean) -> Unit,
|
||||
onMenuVisibilityChanged: (isVisible: Boolean) -> Unit,
|
||||
onDecoderSelected: (decoder: DecoderType) -> Unit,
|
||||
) {
|
||||
PlayerOptionMenu(
|
||||
icon = Icons.Outlined.VideoSettings,
|
||||
contentDescription = R.string.player_controls_decoder_selection_description,
|
||||
items = DecoderType.entries,
|
||||
selectedItem = currentDecoder,
|
||||
onMenuVisibilityChanged = onMenuVisibilityChanged,
|
||||
onItemSelected = onDecoderSelected,
|
||||
itemContent = { decoder, isSelected ->
|
||||
val labelRes = remember(decoder) {
|
||||
when (decoder) {
|
||||
DecoderType.Hardware -> R.string.menu_item_hardware_decoding
|
||||
DecoderType.Software -> R.string.menu_item_software_decoding
|
||||
}
|
||||
itemContent = { decoder ->
|
||||
val labelRes = when (decoder) {
|
||||
DecoderType.Hardware -> R.string.menu_item_hardware_decoding
|
||||
DecoderType.Software -> R.string.menu_item_software_decoding
|
||||
}
|
||||
|
||||
Text(text = stringResource(labelRes))
|
||||
@@ -259,7 +264,7 @@ private fun DecoderOptionsMenu(
|
||||
)
|
||||
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
selected = decoder == currentDecoder,
|
||||
onClick = null,
|
||||
colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colors.primary),
|
||||
)
|
||||
@@ -291,11 +296,10 @@ private fun <T> PlayerOptionMenu(
|
||||
icon: ImageVector,
|
||||
@StringRes contentDescription: Int,
|
||||
items: List<T>,
|
||||
selectedItem: T,
|
||||
onMenuVisibilityChanged: (Boolean) -> Unit,
|
||||
onItemSelected: (T) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
itemContent: @Composable (RowScope.(item: T, isSelected: Boolean) -> Unit),
|
||||
itemContent: @Composable (RowScope.(item: T) -> Unit),
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -316,13 +320,15 @@ private fun <T> PlayerOptionMenu(
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
items.forEach { element ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onItemSelected(element)
|
||||
expanded = false
|
||||
},
|
||||
) {
|
||||
itemContent(element, element == selectedItem)
|
||||
key(element) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onItemSelected(element)
|
||||
expanded = false
|
||||
},
|
||||
) {
|
||||
itemContent(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.jellyfin.mobile.player.ui.config
|
||||
|
||||
data class UiAudioTrack(
|
||||
val label: String,
|
||||
val index: Int,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
@@ -8,4 +8,5 @@ import androidx.compose.runtime.Stable
|
||||
data class UiQualityOption(
|
||||
val label: String,
|
||||
val bitrate: Int?,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
@@ -1,271 +0,0 @@
|
||||
package org.jellyfin.mobile.player.ui.legacy
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.provider.Settings
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.postDelayed
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.dip
|
||||
import org.jellyfin.mobile.utils.extensions.brightness
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.math.abs
|
||||
|
||||
class PlayerGestureHelper(
|
||||
private val fragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val playerLockScreenHelper: PlayerLockScreenHelper,
|
||||
) : KoinComponent {
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val audioManager: AudioManager by lazy { fragment.requireActivity().getSystemService()!! }
|
||||
private val playerView: StyledPlayerView by playerBinding::playerView
|
||||
private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout
|
||||
private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage
|
||||
private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress
|
||||
|
||||
init {
|
||||
if (appPreferences.exoPlayerRememberBrightness) {
|
||||
fragment.requireActivity().window.brightness = appPreferences.exoPlayerBrightness
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether video content should fill the screen, cutting off unwanted content on the sides.
|
||||
* Useful on wide-screen phones to remove black bars from some movies.
|
||||
*/
|
||||
private var isZoomEnabled = false
|
||||
|
||||
/**
|
||||
* Tracks a value during a swipe gesture (between multiple onScroll calls).
|
||||
* When the gesture starts it's reset to an initial value and gets increased or decreased
|
||||
* (depending on the direction) as the gesture progresses.
|
||||
*/
|
||||
private var swipeGestureValueTracker = -1f
|
||||
|
||||
/**
|
||||
* Runnable that hides [playerView] controller
|
||||
*/
|
||||
private val hidePlayerViewControllerAction = Runnable {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runnable that hides [gestureIndicatorOverlayLayout]
|
||||
*/
|
||||
private val hideGestureIndicatorOverlayAction = Runnable {
|
||||
gestureIndicatorOverlayLayout.isVisible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles taps when controls are locked
|
||||
*/
|
||||
private val unlockDetector = GestureDetector(
|
||||
playerView.context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
playerLockScreenHelper.peekUnlockButton()
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles double tap to seek and brightness/volume gestures
|
||||
*/
|
||||
private val gestureDetector = GestureDetector(
|
||||
playerView.context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
val viewWidth = playerView.measuredWidth
|
||||
val viewHeight = playerView.measuredHeight
|
||||
val viewCenterX = viewWidth / 2
|
||||
val viewCenterY = viewHeight / 2
|
||||
val isFastForward = e.x.toInt() > viewCenterX
|
||||
|
||||
// Show ripple effect
|
||||
playerView.foreground?.apply {
|
||||
val left = if (isFastForward) viewCenterX else 0
|
||||
val right = if (isFastForward) viewWidth else viewCenterX
|
||||
setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2)
|
||||
setHotspot(e.x, e.y)
|
||||
state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed)
|
||||
playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) {
|
||||
state = IntArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Fast-forward/rewind
|
||||
with(fragment) { if (isFastForward) onFastForward() else onRewind() }
|
||||
|
||||
// Cancel previous runnable to not hide controller while seeking
|
||||
playerView.removeCallbacks(hidePlayerViewControllerAction)
|
||||
|
||||
// Ensure controller gets hidden after seeking
|
||||
playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
playerView.apply {
|
||||
if (!isControllerFullyVisible) showController() else hideController()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
firstEvent: MotionEvent?,
|
||||
currentEvent: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float,
|
||||
): Boolean {
|
||||
if (!appPreferences.exoPlayerAllowSwipeGestures) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check whether swipe was started in excluded region
|
||||
val exclusionSize = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL)
|
||||
if (
|
||||
firstEvent == null ||
|
||||
firstEvent.y < exclusionSize ||
|
||||
firstEvent.y > playerView.height - exclusionSize
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check whether swipe was oriented vertically
|
||||
if (abs(distanceY / distanceX) < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
val viewCenterX = playerView.measuredWidth / 2
|
||||
|
||||
// Distance to swipe to go from min to max
|
||||
val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO
|
||||
val ratioChange = distanceY / distanceFull
|
||||
|
||||
if (firstEvent.x.toInt() > viewCenterX) {
|
||||
// Swiping on the right, change volume
|
||||
|
||||
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat()
|
||||
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val change = ratioChange * maxVolume
|
||||
swipeGestureValueTracker += change
|
||||
|
||||
val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0)
|
||||
|
||||
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_volume_white_24dp)
|
||||
gestureIndicatorOverlayProgress.max = maxVolume
|
||||
gestureIndicatorOverlayProgress.progress = toSet
|
||||
} else {
|
||||
// Swiping on the left, change brightness
|
||||
|
||||
val window = fragment.requireActivity().window
|
||||
val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL
|
||||
|
||||
// Initialize on first swipe
|
||||
if (swipeGestureValueTracker == -1f) {
|
||||
val brightness = window.brightness
|
||||
swipeGestureValueTracker = when (brightness) {
|
||||
in brightnessRange -> brightness
|
||||
else -> {
|
||||
Settings.System.getFloat(
|
||||
fragment.requireActivity().contentResolver,
|
||||
Settings.System.SCREEN_BRIGHTNESS,
|
||||
) / Constants.SCREEN_BRIGHTNESS_MAX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange)
|
||||
window.brightness = swipeGestureValueTracker
|
||||
if (appPreferences.exoPlayerRememberBrightness) {
|
||||
appPreferences.exoPlayerBrightness = swipeGestureValueTracker
|
||||
}
|
||||
|
||||
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_brightness_white_24dp)
|
||||
gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX
|
||||
gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt()
|
||||
}
|
||||
|
||||
gestureIndicatorOverlayLayout.isVisible = true
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles scale/zoom gesture
|
||||
*/
|
||||
private val zoomGestureDetector = ScaleGestureDetector(
|
||||
playerView.context,
|
||||
object : ScaleGestureDetector.OnScaleGestureListener {
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape()
|
||||
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val scaleFactor = detector.scaleFactor
|
||||
if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) {
|
||||
isZoomEnabled = scaleFactor > 1
|
||||
updateZoomMode(isZoomEnabled)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScaleEnd(detector: ScaleGestureDetector) = Unit
|
||||
},
|
||||
).apply { isQuickScaleEnabled = false }
|
||||
|
||||
init {
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
playerView.setOnTouchListener { _, event ->
|
||||
if (playerView.useController) {
|
||||
when (event.pointerCount) {
|
||||
1 -> gestureDetector.onTouchEvent(event)
|
||||
2 -> zoomGestureDetector.onTouchEvent(event)
|
||||
}
|
||||
} else {
|
||||
unlockDetector.onTouchEvent(event)
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
// Hide gesture indicator after timeout, if shown
|
||||
gestureIndicatorOverlayLayout.apply {
|
||||
if (isVisible) {
|
||||
removeCallbacks(hideGestureIndicatorOverlayAction)
|
||||
postDelayed(
|
||||
hideGestureIndicatorOverlayAction,
|
||||
Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
swipeGestureValueTracker = -1f
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun handleConfiguration(newConfig: Configuration) {
|
||||
updateZoomMode(fragment.isLandscape(newConfig) && isZoomEnabled)
|
||||
}
|
||||
|
||||
private fun updateZoomMode(enabled: Boolean) {
|
||||
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package org.jellyfin.mobile.player.ui.legacy
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.OrientationEventListener
|
||||
import android.widget.ImageButton
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.extensions.lockOrientation
|
||||
import org.jellyfin.mobile.utils.isAutoRotateOn
|
||||
|
||||
class PlayerLockScreenHelper(
|
||||
private val playerFragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val orientationListener: OrientationEventListener,
|
||||
) {
|
||||
private val playerView: StyledPlayerView by playerBinding::playerView
|
||||
private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton
|
||||
|
||||
/**
|
||||
* Runnable that hides the unlock screen button, used by [peekUnlockButton]
|
||||
*/
|
||||
private val hideUnlockButtonAction = Runnable {
|
||||
hideUnlockButton()
|
||||
}
|
||||
|
||||
init {
|
||||
// Handle unlock action
|
||||
unlockScreenButton.setOnClickListener {
|
||||
unlockScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun lockScreen() {
|
||||
playerView.useController = false
|
||||
orientationListener.disable()
|
||||
playerFragment.requireActivity().lockOrientation()
|
||||
peekUnlockButton()
|
||||
}
|
||||
|
||||
private fun unlockScreen() {
|
||||
hideUnlockButton()
|
||||
val activity = playerFragment.requireActivity()
|
||||
if (activity.isAutoRotateOn()) {
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
orientationListener.enable()
|
||||
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
|
||||
playerView.useController = true
|
||||
playerView.apply {
|
||||
if (!isControllerFullyVisible) showController()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekUnlockButton() {
|
||||
playerView.removeCallbacks(hideUnlockButtonAction)
|
||||
unlockScreenButton.isVisible = true
|
||||
playerView.postDelayed(hideUnlockButtonAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
|
||||
}
|
||||
|
||||
fun hideUnlockButton() {
|
||||
unlockScreenButton.isVisible = false
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package org.jellyfin.mobile.player.ui.legacy
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.get
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.size
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.player.ui.config.DecoderType
|
||||
import org.jellyfin.mobile.player.ui.utils.PlaybackInfoBuilder
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* Provides a menu UI for audio, subtitle and video stream selection
|
||||
*/
|
||||
class PlayerMenus(
|
||||
private val fragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val playerControlsBinding: ExoPlayerControlViewBinding,
|
||||
) : PopupMenu.OnDismissListener,
|
||||
KoinComponent {
|
||||
|
||||
private val context = playerBinding.root.context
|
||||
private val playbackInfoBuilder: PlaybackInfoBuilder by inject()
|
||||
private val qualityOptionsProvider: QualityOptionsProvider by inject()
|
||||
private val previousButton: View by playerControlsBinding::previousButton
|
||||
private val nextButton: View by playerControlsBinding::nextButton
|
||||
private val lockScreenButton: View by playerControlsBinding::lockScreenButton
|
||||
private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton
|
||||
private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton
|
||||
private val speedButton: View by playerControlsBinding::speedButton
|
||||
private val qualityButton: View by playerControlsBinding::qualityButton
|
||||
private val decoderButton: View by playerControlsBinding::decoderButton
|
||||
private val infoButton: View by playerControlsBinding::infoButton
|
||||
private val playbackInfo: TextView by playerBinding::playbackInfo
|
||||
private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu()
|
||||
private val subtitlesMenu: PopupMenu = createSubtitlesMenu()
|
||||
private val speedMenu: PopupMenu = createSpeedMenu()
|
||||
private val qualityMenu: PopupMenu = createQualityMenu()
|
||||
private val decoderMenu: PopupMenu = createDecoderMenu()
|
||||
|
||||
private var subtitleCount = 0
|
||||
private var subtitlesEnabled = false
|
||||
|
||||
init {
|
||||
previousButton.setOnClickListener {
|
||||
fragment.onSkipToPrevious()
|
||||
}
|
||||
nextButton.setOnClickListener {
|
||||
fragment.onSkipToNext()
|
||||
}
|
||||
lockScreenButton.setOnClickListener {
|
||||
fragment.playerLockScreenHelper.lockScreen()
|
||||
}
|
||||
audioStreamsButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
audioStreamsMenu.show()
|
||||
}
|
||||
subtitlesButton.setOnClickListener {
|
||||
when (subtitleCount) {
|
||||
0 -> return@setOnClickListener
|
||||
1 -> {
|
||||
fragment.toggleSubtitles { enabled ->
|
||||
subtitlesEnabled = enabled
|
||||
updateSubtitlesButton()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
subtitlesMenu.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
speedButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
speedMenu.show()
|
||||
}
|
||||
qualityButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
qualityMenu.show()
|
||||
}
|
||||
decoderButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
decoderMenu.show()
|
||||
}
|
||||
infoButton.setOnClickListener {
|
||||
playbackInfo.isVisible = !playbackInfo.isVisible
|
||||
}
|
||||
playbackInfo.setOnClickListener {
|
||||
dismissPlaybackInfo()
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) {
|
||||
// previousButton is always enabled and will rewind if at the start of the queue
|
||||
nextButton.isEnabled = hasNext
|
||||
|
||||
val videoStream = mediaSource.selectedVideoStream
|
||||
|
||||
val audioStreams = mediaSource.audioStreams
|
||||
buildMenuItems(
|
||||
audioStreamsMenu.menu,
|
||||
AUDIO_MENU_GROUP,
|
||||
audioStreams,
|
||||
mediaSource.selectedAudioStream,
|
||||
)
|
||||
|
||||
val subtitleStreams = mediaSource.subtitleStreams
|
||||
val selectedSubtitleStream = mediaSource.selectedSubtitleStream
|
||||
buildMenuItems(
|
||||
subtitlesMenu.menu,
|
||||
SUBTITLES_MENU_GROUP,
|
||||
subtitleStreams,
|
||||
selectedSubtitleStream,
|
||||
true,
|
||||
)
|
||||
subtitleCount = subtitleStreams.size
|
||||
subtitlesEnabled = selectedSubtitleStream != null
|
||||
|
||||
updateSubtitlesButton()
|
||||
|
||||
val height = videoStream?.height
|
||||
val width = videoStream?.width
|
||||
if (height != null && width != null) {
|
||||
buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height)
|
||||
} else {
|
||||
qualityButton.isVisible = false
|
||||
}
|
||||
|
||||
playbackInfo.text = playbackInfoBuilder.buildPlaybackInfo(fragment.resources, mediaSource)
|
||||
}
|
||||
|
||||
private fun createSubtitlesMenu() = PopupMenu(context, subtitlesButton).apply {
|
||||
setOnMenuItemClickListener { clickedItem ->
|
||||
// Immediately apply changes to the menu, necessary when direct playing
|
||||
// When transcoding, the updated media source will cause the menu to be rebuilt
|
||||
clickedItem.isChecked = true
|
||||
|
||||
// The itemId is the MediaStream.index of the track
|
||||
val selectedSubtitleStreamIndex = clickedItem.itemId
|
||||
fragment.onSubtitleSelected(selectedSubtitleStreamIndex) {
|
||||
subtitlesEnabled = selectedSubtitleStreamIndex >= 0
|
||||
updateSubtitlesButton()
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createAudioStreamsMenu() = PopupMenu(context, audioStreamsButton).apply {
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
// Immediately apply changes to the menu, necessary when direct playing
|
||||
// When transcoding, the updated media source will cause the menu to be rebuilt
|
||||
clickedItem.isChecked = true
|
||||
|
||||
// The itemId is the MediaStream.index of the track
|
||||
fragment.onAudioTrackSelected(clickedItem.itemId) {}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createSpeedMenu() = PopupMenu(context, speedButton).apply {
|
||||
for (step in SPEED_MENU_STEP_MIN..SPEED_MENU_STEP_MAX) {
|
||||
val newSpeed = step * SPEED_MENU_STEP_SIZE
|
||||
menu.add(SPEED_MENU_GROUP, step, Menu.NONE, "${newSpeed}x").isChecked = newSpeed == 1f
|
||||
}
|
||||
menu.setGroupCheckable(SPEED_MENU_GROUP, true, true)
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
fragment.onSpeedSelected(clickedItem.itemId * SPEED_MENU_STEP_SIZE).also { success ->
|
||||
if (success) clickedItem.isChecked = true
|
||||
}
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createQualityMenu() = PopupMenu(context, qualityButton).apply {
|
||||
setOnMenuItemClickListener { item: MenuItem ->
|
||||
val newBitrate = item.itemId.takeUnless { bitrate -> bitrate == 0 }
|
||||
fragment.onBitrateChanged(newBitrate) {
|
||||
// Ignore callback - menu will be recreated if bitrate changes
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createDecoderMenu() = PopupMenu(context, qualityButton).apply {
|
||||
menu.add(
|
||||
DECODER_MENU_GROUP,
|
||||
DecoderType.Hardware.ordinal,
|
||||
Menu.NONE,
|
||||
context.getString(R.string.menu_item_hardware_decoding),
|
||||
)
|
||||
menu.add(
|
||||
DECODER_MENU_GROUP,
|
||||
DecoderType.Software.ordinal,
|
||||
Menu.NONE,
|
||||
context.getString(R.string.menu_item_software_decoding),
|
||||
)
|
||||
menu.setGroupCheckable(DECODER_MENU_GROUP, true, true)
|
||||
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
val type = DecoderType.values()[clickedItem.itemId]
|
||||
fragment.onDecoderSelected(type)
|
||||
clickedItem.isChecked = true
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
fun updatedSelectedDecoder(type: DecoderType) {
|
||||
decoderMenu.menu.findItem(type.ordinal).isChecked = true
|
||||
}
|
||||
|
||||
private fun buildMenuItems(
|
||||
menu: Menu,
|
||||
groupId: Int,
|
||||
mediaStreams: List<MediaStream>,
|
||||
selectedStream: MediaStream?,
|
||||
showNone: Boolean = false,
|
||||
) {
|
||||
menu.clear()
|
||||
val itemNone = when {
|
||||
showNone -> menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none))
|
||||
else -> null
|
||||
}
|
||||
var selectedItem: MenuItem? = itemNone
|
||||
val menuItems = mediaStreams.map { mediaStream ->
|
||||
val title = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})"
|
||||
menu.add(groupId, mediaStream.index, Menu.NONE, title).also { item ->
|
||||
if (mediaStream === selectedStream) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
menu.setGroupCheckable(groupId, true, true)
|
||||
// Check selected item or first item if possible
|
||||
(selectedItem ?: menuItems.firstOrNull())?.isChecked = true
|
||||
}
|
||||
|
||||
private fun updateSubtitlesButton() {
|
||||
subtitlesButton.isVisible = subtitleCount > 0
|
||||
val stateSet = intArrayOf(android.R.attr.state_checked * if (subtitlesEnabled) 1 else -1)
|
||||
subtitlesButton.setImageState(stateSet, true)
|
||||
}
|
||||
|
||||
private fun buildQualityMenu(menu: Menu, maxStreamingBitrate: Int?, videoWidth: Int, videoHeight: Int) {
|
||||
menu.clear()
|
||||
val options = qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight)
|
||||
options.forEach { option ->
|
||||
val title = when (val bitrate = option.bitrate ?: 0) {
|
||||
0 -> context.getString(R.string.menu_item_auto)
|
||||
else -> "${option.maxHeight}p - ${playbackInfoBuilder.formatBitrate(bitrate.toDouble())}"
|
||||
}
|
||||
menu.add(QUALITY_MENU_GROUP, option.bitrate ?: 0, Menu.NONE, title)
|
||||
}
|
||||
menu.setGroupCheckable(QUALITY_MENU_GROUP, true, true)
|
||||
|
||||
val selection = maxStreamingBitrate?.let(menu::findItem) ?: menu[menu.size - 1] // Last element is "auto"
|
||||
selection.isChecked = true
|
||||
}
|
||||
|
||||
fun dismissPlaybackInfo() {
|
||||
playbackInfo.isVisible = false
|
||||
}
|
||||
|
||||
override fun onDismiss(menu: PopupMenu) {
|
||||
fragment.suppressControllerAutoHide(false)
|
||||
fragment.onPopupDismissed()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SUBTITLES_MENU_GROUP = 0
|
||||
private const val AUDIO_MENU_GROUP = 1
|
||||
private const val SPEED_MENU_GROUP = 2
|
||||
private const val QUALITY_MENU_GROUP = 3
|
||||
private const val DECODER_MENU_GROUP = 4
|
||||
|
||||
private const val SPEED_MENU_STEP_SIZE = 0.25f
|
||||
private const val SPEED_MENU_STEP_MIN = 2 // → 0.5x
|
||||
private const val SPEED_MENU_STEP_MAX = 8 // → 2x
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.jellyfin.mobile.player.ui.legacy
|
||||
|
||||
fun interface TrackSelectionCallback {
|
||||
fun onTrackSelected(success: Boolean)
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import org.jellyfin.mobile.utils.extensions.hasFlag
|
||||
|
||||
class FullscreenHelper(private val window: Window) {
|
||||
private val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
private var isFullscreen: Boolean = false
|
||||
var isFullscreen: Boolean = false
|
||||
private set
|
||||
|
||||
fun onWindowInsetsChanged(insets: WindowInsetsCompat) {
|
||||
isFullscreen = when {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.annotation.StringRes
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.config.UiAudioTrack
|
||||
import org.jellyfin.mobile.player.ui.config.UiQualityOption
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import java.util.Locale
|
||||
@@ -12,10 +13,39 @@ import java.util.Locale
|
||||
class PlaybackInfoBuilder(
|
||||
private val qualityOptionsProvider: QualityOptionsProvider,
|
||||
) {
|
||||
fun buildPlaybackInfo(
|
||||
resources: Resources,
|
||||
mediaSource: JellyfinMediaSource,
|
||||
): String {
|
||||
fun buildAudioStreams(mediaSource: JellyfinMediaSource): List<UiAudioTrack> {
|
||||
return mediaSource.audioStreams.map { mediaStream ->
|
||||
UiAudioTrack(
|
||||
label = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})",
|
||||
index = mediaStream.index,
|
||||
isSelected = mediaStream === mediaSource.selectedAudioStream,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildQualityOptions(resources: Resources, mediaSource: JellyfinMediaSource): List<UiQualityOption> {
|
||||
val videoStream = mediaSource.selectedVideoStream ?: return emptyList()
|
||||
val videoWidth = videoStream.width ?: 0
|
||||
val videoHeight = videoStream.height ?: 0
|
||||
|
||||
if (videoWidth == 0 || videoHeight == 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight).map { option ->
|
||||
val title = when (val bitrate = option.bitrate) {
|
||||
null -> resources.getString(R.string.menu_item_auto)
|
||||
else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}"
|
||||
}
|
||||
UiQualityOption(
|
||||
label = title,
|
||||
bitrate = option.bitrate,
|
||||
isSelected = option.bitrate == mediaSource.maxStreamingBitrate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildPlaybackInfo(resources: Resources, mediaSource: JellyfinMediaSource): String {
|
||||
val videoStream = mediaSource.selectedVideoStream
|
||||
val audioStreams = mediaSource.audioStreams
|
||||
|
||||
@@ -64,28 +94,7 @@ class PlaybackInfoBuilder(
|
||||
"- $title$suffix"
|
||||
}
|
||||
|
||||
fun buildQualityOptions(
|
||||
resources: Resources,
|
||||
jellyfinMediaSource: JellyfinMediaSource,
|
||||
): List<UiQualityOption> {
|
||||
val videoStream = jellyfinMediaSource.selectedVideoStream ?: return emptyList()
|
||||
val videoWidth = videoStream.width ?: 0
|
||||
val videoHeight = videoStream.height ?: 0
|
||||
|
||||
if (videoWidth == 0 || videoHeight == 0) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight).map { option ->
|
||||
val title = when (val bitrate = option.bitrate) {
|
||||
null -> resources.getString(R.string.menu_item_auto)
|
||||
else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}"
|
||||
}
|
||||
UiQualityOption(title, option.bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBitrate(bitrate: Double): String {
|
||||
private fun formatBitrate(bitrate: Double): String {
|
||||
val (value, unit) = when {
|
||||
bitrate > BITRATE_MEGA_BIT -> bitrate / BITRATE_MEGA_BIT to " Mbps"
|
||||
bitrate > BITRATE_KILO_BIT -> bitrate / BITRATE_KILO_BIT to " kbps"
|
||||
|
||||
Reference in new issue
Block a user