From f39e6f04cf3a1cc80afa00eed67192e2e96e3910 Mon Sep 17 00:00:00 2001 From: Maxr1998 Date: Sun, 20 Aug 2023 18:28:07 +0200 Subject: [PATCH] More tweaks --- .../mobile/player/interaction/PlayerEvent.kt | 8 +- .../jellyfin/mobile/player/ui/Constants.kt | 11 ++ .../mobile/player/ui/PlayerFragment.kt | 11 +- .../mobile/player/ui/PlayerGestureHelper.kt | 2 +- .../mobile/player/ui/PlayerOverlay.kt | 31 ++++-- .../jellyfin/mobile/player/ui/PlayerScreen.kt | 101 ++++++++++++++---- .../player/ui/controls/PlaybackProgress.kt | 11 +- .../player/ui/controls/PlayerControls.kt | 5 +- .../player/ui/controls/PlayerOptions.kt | 8 +- .../jellyfin/mobile/utils/MediaExtensions.kt | 2 +- .../org/jellyfin/mobile/utils/UIExtensions.kt | 9 -- .../mobile/utils/extensions/Configuration.kt | 6 ++ .../mobile/utils/extensions/Window.kt | 8 ++ detekt.yml | 4 + 14 files changed, 157 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/utils/extensions/Configuration.kt diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt index 4c8ed606..3515f91f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerEvent.kt @@ -1,10 +1,10 @@ package org.jellyfin.mobile.player.interaction sealed class PlayerEvent { - object Pause : PlayerEvent() - object Resume : PlayerEvent() - object Stop : PlayerEvent() - object Destroy : PlayerEvent() + data object Pause : PlayerEvent() + data object Resume : PlayerEvent() + data object Stop : PlayerEvent() + data object Destroy : PlayerEvent() data class Seek(val ms: Long) : PlayerEvent() data class SetVolume(val volume: Int) : PlayerEvent() } 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 new file mode 100644 index 00000000..15e1da11 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/Constants.kt @@ -0,0 +1,11 @@ +package org.jellyfin.mobile.player.ui + +const val LockButtonTimeout = 1000L +const val ControlsTimeout = 3000L +const val ShowControlsAnimationDuration = 60 +const val HideControlsAnimationDuration = 120 + +// Player gestures +const val DoubleTapRippleDurationMs = 100L +const val ZoomScaleBase = 1f +const val ZoomScaleThreshold = 0.01f 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 88fc4305..5cb5a877 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 @@ -44,15 +44,14 @@ import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL import org.jellyfin.mobile.utils.SmartOrientationListener import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins -import org.jellyfin.mobile.utils.brightness import org.jellyfin.mobile.utils.extensions.aspectRational +import org.jellyfin.mobile.utils.extensions.brightness import org.jellyfin.mobile.utils.extensions.getParcelableCompat import org.jellyfin.mobile.utils.extensions.isLandscape import org.jellyfin.mobile.utils.extensions.keepScreenOn import org.jellyfin.mobile.utils.toast import org.jellyfin.sdk.model.api.MediaStream import org.koin.android.ext.android.inject -import timber.log.Timber class PlayerFragment : Fragment(), BackPressInterceptor { private val appPreferences: AppPreferences by inject() @@ -63,7 +62,6 @@ class PlayerFragment : Fragment(), BackPressInterceptor { private val composeView: ComposeView get() = viewBinding.composeView private val controlsState = mutableStateOf(ControlsState.Hidden) - private val inhibitControlsState = mutableStateOf(false) private var playerLocation = Rect() private var playerMenus: PlayerMenus? = null @@ -92,15 +90,12 @@ class PlayerFragment : Fragment(), BackPressInterceptor { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.player.collect { player -> - Timber.d("Player changed: $player") if (player == null) parentFragmentManager.popBackStack() } } } - viewModel.playerState.observe(this) { playerState -> - val isPlaying = viewModel.playerOrNull?.isPlaying == true - requireActivity().window.keepScreenOn = isPlaying - //loadingIndicator.isVisible = playerState == Player.STATE_BUFFERING + viewModel.playerState.observe(this) { + requireActivity().window.keepScreenOn = viewModel.playerOrNull?.isPlaying == true } viewModel.decoderType.observe(this) { type -> playerMenus?.updatedSelectedDecoder(type) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt index d14abda6..23e22f8a 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerGestureHelper.kt @@ -20,8 +20,8 @@ import org.jellyfin.mobile.R import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.databinding.FragmentPlayerBinding import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.brightness 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 diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerOverlay.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerOverlay.kt index 4d918b73..e7a14e66 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerOverlay.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerOverlay.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -51,9 +52,6 @@ import org.jellyfin.mobile.utils.shouldShowNextButton import org.jellyfin.mobile.utils.shouldShowPauseButton import org.jellyfin.mobile.utils.shouldShowPreviousButton -const val ShowControlsAnimationDuration = 60 -const val HideControlsAnimationDuration = 120 - @Suppress("LongMethod") @Composable fun PlayerOverlay( @@ -68,7 +66,7 @@ fun PlayerOverlay( 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 { mutableStateOf(player.duration) } + var duration by remember { mutableLongStateOf(player.duration) } PlayerEventsHandler( player = player, @@ -117,8 +115,8 @@ fun PlayerOverlay( shouldShowNextButton = shouldShowNextButton, playerPosition = playerPosition, duration = duration, - onMenuVisibilityChanged = { visible -> - controlsState.value = if (visible) ControlsState.ForceVisible else ControlsState.Visible + onSuppressControlsTimeout = { suppressed -> + controlsState.value = if (suppressed) ControlsState.ForceVisible else ControlsState.Visible }, onLockControls = { controlsState.value = ControlsState.Locked @@ -131,12 +129,25 @@ fun PlayerOverlay( ) } - if (shouldShowLoadingIndicator) { + AnimatedVisibility( + visible = shouldShowLoadingIndicator, + modifier = Modifier.align(Alignment.Center), + enter = fadeIn( + animationSpec = tween( + durationMillis = ShowControlsAnimationDuration, + easing = LinearOutSlowInEasing, + ), + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = HideControlsAnimationDuration, + easing = FastOutLinearInEasing, + ), + ), + ) { CircularProgressIndicator( strokeWidth = 6.dp, - modifier = Modifier - .size(58.dp) - .align(Alignment.Center), + modifier = Modifier.size(58.dp), ) } diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerScreen.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerScreen.kt index e081ce3b..2b943832 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerScreen.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/PlayerScreen.kt @@ -4,56 +4,115 @@ import android.graphics.Rect import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.ContentAlpha import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.player.ui.controls.ControlsState -import timber.log.Timber +import org.jellyfin.mobile.utils.extensions.isLandscape +import kotlin.math.abs import com.google.android.exoplayer2.ui.R as ExoplayerR -const val PlayerControlsTimeout = 3000L -const val LockButtonTimeout = 1000L - -@OptIn(ExperimentalComposeUiApi::class) +@Suppress("LongMethod") @Composable fun PlayerScreen( playerViewModel: PlayerViewModel = viewModel(), controlsState: MutableState, onContentLocationUpdated: (Rect) -> Unit, ) { + val coroutineScope = rememberCoroutineScope() + val isLandscape by rememberUpdatedState(LocalConfiguration.current.isLandscape) val player by playerViewModel.player.collectAsState() + val rippleInteractionSource = remember { MutableInteractionSource() } + var contentSize by remember { mutableStateOf(IntSize.Zero) } + var isZoomEnabled by remember { mutableStateOf(false) } Box( modifier = Modifier .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - when (controlsState.value) { - ControlsState.Hidden -> controlsState.value = ControlsState.Visible - ControlsState.Locked -> controlsState.value = ControlsState.IndicateLocked - ControlsState.Visible, ControlsState.ForceVisible -> controlsState.value = ControlsState.Hidden - else -> Unit // do nothing + .onSizeChanged { size -> + contentSize = size + } + .indication( + interactionSource = rippleInteractionSource, + indication = rememberRipple( + bounded = false, + radius = with(LocalDensity.current) { + (contentSize.width / 2).toDp() + }, + ), + ) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + when (controlsState.value) { + ControlsState.Hidden -> controlsState.value = ControlsState.Visible + ControlsState.Locked -> controlsState.value = ControlsState.IndicateLocked + ControlsState.Visible, ControlsState.ForceVisible -> controlsState.value = ControlsState.Hidden + else -> Unit // do nothing + } + }, + onDoubleTap = { event -> + val (contentWidth, contentHeight) = contentSize + val contentCenterX = contentWidth / 2 + val contentCenterY = contentHeight / 2 + val isFastForward = event.x.toInt() > contentCenterX + + // Show ripple effect + coroutineScope.launch { + val press = PressInteraction.Press(event) + rippleInteractionSource.emit(press) + delay(DoubleTapRippleDurationMs) + rippleInteractionSource.emit(PressInteraction.Release(press)) + } + + // Fast-forward/rewind + when { + isFastForward -> playerViewModel.fastForward() + else -> playerViewModel.rewind() + } + }, + ) + } + .pointerInput(Unit) { + detectTransformGestures( + panZoomLock = true, + ) { _, _, zoom, _ -> + if (isLandscape && abs(zoom - ZoomScaleBase) > ZoomScaleThreshold) { + isZoomEnabled = zoom > 1 + } } }, ) { @@ -79,8 +138,12 @@ fun PlayerScreen( modifier = Modifier.fillMaxSize(), onReset = {}, update = { playerView -> - Timber.d("Updating player view") - playerView.player = player + playerView.player = player // setter will handle repeated calls with the same player + + playerView.resizeMode = when { + isZoomEnabled && isLandscape -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM + else -> AspectRatioFrameLayout.RESIZE_MODE_FIT + } }, onRelease = { playerView -> playerView.player = null @@ -103,7 +166,7 @@ fun PlayerScreen( LaunchedEffect(controlsState.value) { when (controlsState.value) { ControlsState.Visible -> { - delay(PlayerControlsTimeout) + delay(ControlsTimeout) controlsState.value = ControlsState.Hidden } ControlsState.IndicateLocked -> { diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlaybackProgress.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlaybackProgress.kt index a68a0c18..ef4f80f9 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlaybackProgress.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlaybackProgress.kt @@ -11,8 +11,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -31,10 +32,11 @@ fun PlaybackProgress( modifier: Modifier = Modifier, position: PlayerPosition, duration: Long, + onSuppressControlsTimeout: (suppressed: Boolean) -> Unit, onSeek: (Long) -> Unit, ) { val formatter = remember { TimeFormatter() } - var seekPosition by remember { mutableStateOf(0f) } + var seekPosition by remember { mutableFloatStateOf(0f) } val interactionSource = remember { MutableInteractionSource() } val isDragged by interactionSource.collectIsDraggedAsState() val thumbRadius by animateDpAsState( @@ -42,6 +44,11 @@ fun PlaybackProgress( label = "Thumb radius", ) + // Suppress controls timeout while seeking + LaunchedEffect(isDragged) { + onSuppressControlsTimeout(isDragged) + } + Column( modifier = modifier .padding(horizontal = 16.dp) diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerControls.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerControls.kt index 41bfdbab..08a31825 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerControls.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerControls.kt @@ -36,7 +36,7 @@ fun PlayerControls( shouldShowNextButton: Boolean, playerPosition: PlayerPosition, duration: Long, - onMenuVisibilityChanged: (Boolean) -> Unit, + onSuppressControlsTimeout: (suppressed: Boolean) -> Unit, onLockControls: () -> Unit, onToggleInfo: () -> Unit, modifier: Modifier = Modifier, @@ -73,6 +73,7 @@ fun PlayerControls( PlaybackProgress( position = playerPosition, duration = duration, + onSuppressControlsTimeout = onSuppressControlsTimeout, onSeek = { position -> player.seekTo(position) }, @@ -86,7 +87,7 @@ fun PlayerControls( selectedSubtitle = null, ), isInFullscreen = @OptIn(ExperimentalLayoutApi::class) !WindowInsets.areStatusBarsVisible, - onMenuVisibilityChanged = onMenuVisibilityChanged, + onSuppressControlsTimeout = onSuppressControlsTimeout, onLockControls = onLockControls, onShowAudioTracks = { /*TODO*/ }, onSubtitleSelected = { /*TODO*/ }, diff --git a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerOptions.kt b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerOptions.kt index 350a1316..afb6170f 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerOptions.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/ui/controls/PlayerOptions.kt @@ -43,13 +43,13 @@ import org.jellyfin.mobile.player.ui.PlaybackInfoHelper import org.jellyfin.sdk.model.api.MediaStream import org.koin.compose.koinInject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LongMethod") @Composable fun PlayerOptions( mediaSource: JellyfinMediaSource?, subtitleState: SubtitleControlsState, isInFullscreen: Boolean, - onMenuVisibilityChanged: (Boolean) -> Unit, + onSuppressControlsTimeout: (suppressed: Boolean) -> Unit, onLockControls: () -> Unit, onShowAudioTracks: () -> Unit, onSubtitleSelected: (MediaStream) -> Unit, @@ -113,7 +113,7 @@ fun PlayerOptions( if (mediaSource != null) { QualityOptionsButton( jellyfinMediaSource = mediaSource, - onMenuVisibilityChanged = onMenuVisibilityChanged, + onMenuVisibilityChanged = onSuppressControlsTimeout, onBitrateSelected = onBitrateSelected, ) } @@ -145,7 +145,7 @@ fun PlayerOptions( @Composable private fun QualityOptionsButton( jellyfinMediaSource: JellyfinMediaSource, - onMenuVisibilityChanged: (Boolean) -> Unit, + onMenuVisibilityChanged: (visible: Boolean) -> Unit, onBitrateSelected: (Int?) -> Unit, playbackInfoHelper: PlaybackInfoHelper = koinInject(), ) { diff --git a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt index 7665a4e6..abc81ba7 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/MediaExtensions.kt @@ -80,7 +80,7 @@ inline fun ExoPlayer.applyDefaultAudioAttributes(@C.AudioContentType contentType .setUsage(C.USAGE_MEDIA) .setContentType(contentType) .build() - setAudioAttributes(audioAttributes, true) + setAudioAttributes(audioAttributes, false) // TODO: set audio focus to true again } /** diff --git a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt index aebe3a9d..4dc01f09 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt @@ -8,7 +8,6 @@ import android.view.ContextThemeWrapper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.Window import android.widget.Toast import androidx.annotation.StringRes import androidx.annotation.StyleRes @@ -61,11 +60,3 @@ fun View.fadeIn() { } inline fun Resources.dip(px: Int) = (px * displayMetrics.density).toInt() - -inline var Window.brightness: Float - get() = attributes.screenBrightness - set(value) { - attributes = attributes.apply { - screenBrightness = value - } - } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Configuration.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Configuration.kt new file mode 100644 index 00000000..21723807 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Configuration.kt @@ -0,0 +1,6 @@ +package org.jellyfin.mobile.utils.extensions + +import android.content.res.Configuration + +inline val Configuration.isLandscape: Boolean + get() = orientation == Configuration.ORIENTATION_LANDSCAPE diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt index a1568d3a..412b7f77 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Window.kt @@ -3,6 +3,14 @@ package org.jellyfin.mobile.utils.extensions import android.view.Window import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON +inline var Window.brightness: Float + get() = attributes.screenBrightness + set(value) { + attributes = attributes.apply { + screenBrightness = value + } + } + inline var Window.keepScreenOn: Boolean get() = attributes.flags.hasFlag(FLAG_KEEP_SCREEN_ON) set(value) = if (value) addFlags(FLAG_KEEP_SCREEN_ON) else clearFlags(FLAG_KEEP_SCREEN_ON) diff --git a/detekt.yml b/detekt.yml index a24286fa..87923ae0 100644 --- a/detekt.yml +++ b/detekt.yml @@ -44,6 +44,10 @@ naming: PackageNaming: # Package names must be lowercase letters packagePattern: '[a-z]+(\.[a-z]+)*' + TopLevelPropertyNaming: + # Jetpack Compose API guidelines encourage PascalCase + # https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md + constantPattern: '[A-Za-z][_A-Za-z0-9]*' performance: SpreadOperator: