diff --git a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt index cd4d2c92..11e5be84 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/TrackSelectionHelper.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt index b6f01863..a6884b76 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt index 2d4c2b7f..8fa207b1 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerFragment.kt @@ -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) } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlaybackInfo.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlaybackInfo.kt index 65aa91cf..cbe21aed 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlaybackInfo.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlaybackInfo.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerOverlay.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerOverlay.kt index f86c597d..179d3a3c 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerOverlay.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerOverlay.kt @@ -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 }, diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerScreen.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerScreen.kt index 752882e9..6f923449 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerScreen.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/components/PlayerScreen.kt @@ -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.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, diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerControls.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerControls.kt index 71278520..974461f7 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerControls.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerControls.kt @@ -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, playbackSpeed: Float, + qualityOptions: List, 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) }, diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerOptions.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerOptions.kt index 7f46b42a..f89dabed 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerOptions.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/components/controls/PlayerOptions.kt @@ -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, subtitleState: SubtitleControlsState, playbackSpeed: Float, + qualityOptions: List, 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, + 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, + 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 PlayerOptionMenu( icon: ImageVector, @StringRes contentDescription: Int, items: List, - 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 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) + } } } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiAudioTrack.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiAudioTrack.kt new file mode 100644 index 00000000..a1882789 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiAudioTrack.kt @@ -0,0 +1,7 @@ +package org.jellyfin.mobile.player.ui.config + +data class UiAudioTrack( + val label: String, + val index: Int, + val isSelected: Boolean, +) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiQualityOption.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiQualityOption.kt index a528a55a..86bca4f9 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiQualityOption.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/config/UiQualityOption.kt @@ -8,4 +8,5 @@ import androidx.compose.runtime.Stable data class UiQualityOption( val label: String, val bitrate: Int?, + val isSelected: Boolean, ) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerGestureHelper.kt deleted file mode 100644 index 9dc3b53a..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerGestureHelper.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerLockScreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerLockScreenHelper.kt deleted file mode 100644 index e79964a3..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerLockScreenHelper.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerMenus.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerMenus.kt deleted file mode 100644 index 67a26b01..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/PlayerMenus.kt +++ /dev/null @@ -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, - 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 - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/TrackSelectionCallback.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/TrackSelectionCallback.kt deleted file mode 100644 index 65c3e03e..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/legacy/TrackSelectionCallback.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.jellyfin.mobile.player.ui.legacy - -fun interface TrackSelectionCallback { - fun onTrackSelected(success: Boolean) -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/utils/FullscreenHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/utils/FullscreenHelper.kt index 135f119c..12a40217 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/utils/FullscreenHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/utils/FullscreenHelper.kt @@ -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 { diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/utils/PlaybackInfoBuilder.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/utils/PlaybackInfoBuilder.kt index 031c565f..6faccd50 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/utils/PlaybackInfoBuilder.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/utils/PlaybackInfoBuilder.kt @@ -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 { + 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 { + 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 { - 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"