More tweaks

This commit is contained in:
Maxr1998
2023-08-20 18:28:07 +02:00
parent 7a72b3ba99
commit f39e6f04cf
14 changed files with 157 additions and 60 deletions
@@ -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()
}
@@ -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
@@ -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)
@@ -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
@@ -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),
)
}
@@ -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<ControlsState>,
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 -> {
@@ -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)
@@ -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*/ },
@@ -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(),
) {
@@ -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
}
/**
@@ -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
}
}
@@ -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
@@ -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)
+4
View File
@@ -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: