mirror of
https://github.com/jellyfin/jellyfin-android.git
synced 2026-09-03 05:10:27 +03:00
More tweaks
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user