Audio selection and initial cleanup

This commit is contained in:
Maxr1998 committed 2023-08-23 00:15:20 +02:00
1 parent 25afb426ed
commit e16791a39c
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,
@@ -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)
},
@@ -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"