mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-24 03:36:51 +00:00
refactor(player): move controls auto-hide from ViewModel to composable layer
This commit is contained in:
@@ -24,10 +24,10 @@ import androidx.compose.material.icons.outlined.VolumeUp
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.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.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,23 +45,27 @@ import hu.bbara.purefin.core.player.model.TimedMarker
|
||||
import hu.bbara.purefin.core.player.viewmodel.PlayerViewModel
|
||||
import hu.bbara.purefin.ui.common.visual.EmptyValueTimedVisibility
|
||||
import hu.bbara.purefin.ui.common.visual.ValueChangeTimedVisibility
|
||||
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PersistentOverlayContainer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerAdjustmentIndicator
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerControlsOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerGesturesLayer
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerLoadingErrorEndCard
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerQueuePanel
|
||||
import hu.bbara.purefin.ui.screen.player.components.NextEpisodeOverlay
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerSeekBarTrack
|
||||
import hu.bbara.purefin.ui.screen.player.components.PlayerTimeRow
|
||||
import hu.bbara.purefin.ui.screen.player.components.SkipSegmentButton
|
||||
import hu.bbara.purefin.ui.screen.player.components.rememberPersistentOverlayController
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION = 0.32f
|
||||
/** Small negative band below zero that represents adaptive/auto brightness mode. */
|
||||
private const val AUTO_BRIGHTNESS_THRESHOLD = -0.15f
|
||||
private const val CONTROLS_AUTO_HIDE_MS = 3_000L
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
@@ -69,8 +73,13 @@ fun PlayerScreen(
|
||||
viewModel: PlayerViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var hideControlsJob: Job? by remember { mutableStateOf(null) }
|
||||
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val controlsVisible by viewModel.controlsVisible.collectAsStateWithLifecycle()
|
||||
var controlsVisible by remember { mutableStateOf(true) }
|
||||
var controlsHasPopupOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? Activity
|
||||
|
||||
@@ -83,20 +92,43 @@ fun PlayerScreen(
|
||||
var horizontalSeekPreviewPositionMs by remember { mutableStateOf<Long?>(null) }
|
||||
val overlayController = rememberPersistentOverlayController()
|
||||
|
||||
LaunchedEffect(uiState.isPlaying) {
|
||||
if (uiState.isPlaying) {
|
||||
showQueuePanel = false
|
||||
}
|
||||
}
|
||||
|
||||
val playerControlsVisible = controlsVisible || uiState.isEnded || uiState.error != null
|
||||
val subtitleBottomPaddingFraction =
|
||||
if (playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
CONTROLS_VISIBLE_SUBTITLE_BOTTOM_PADDING_FRACTION
|
||||
} else {
|
||||
SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION
|
||||
}
|
||||
|
||||
|
||||
val showSkipIntroButton = !controlsVisible && uiState.activeSkippableSegmentEndMs != null
|
||||
val showNextEpisodeOverlay = !controlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||
&& !uiState.isEnded
|
||||
|
||||
|
||||
fun toggleControlsVisibility() {
|
||||
controlsVisible = !controlsVisible
|
||||
}
|
||||
fun hideControls() {
|
||||
controlsVisible = false
|
||||
}
|
||||
fun hideControlsWithTimeout() {
|
||||
hideControlsJob?.cancel()
|
||||
if (controlsVisible && !controlsHasPopupOpen && !uiState.isEnded && uiState.error == null) {
|
||||
hideControlsJob = scope.launch {
|
||||
delay(CONTROLS_AUTO_HIDE_MS)
|
||||
hideControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun onScreenTap() {
|
||||
toggleControlsVisibility()
|
||||
hideControlsWithTimeout()
|
||||
}
|
||||
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -121,7 +153,7 @@ fun PlayerScreen(
|
||||
|
||||
PlayerGesturesLayer(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
onTap = { viewModel.toggleControlsVisibility() },
|
||||
onTap = ::onScreenTap,
|
||||
onDoubleTapRight = { viewModel.seekBy(30_000) },
|
||||
onDoubleTapLeft = { viewModel.seekBy(-10_000) },
|
||||
onDoubleTapCenter = { viewModel.togglePlayPause() },
|
||||
@@ -152,7 +184,7 @@ fun PlayerScreen(
|
||||
onHorizontalDragSeekTo = {
|
||||
viewModel.seekTo(it)
|
||||
},
|
||||
currentPositionProvider = { uiState.positionMs }
|
||||
currentPositionProvider = { uiState.positionMs },
|
||||
)
|
||||
|
||||
EmptyValueTimedVisibility(
|
||||
@@ -170,7 +202,7 @@ fun PlayerScreen(
|
||||
value = horizontalSeekPreviewPositionMs,
|
||||
hideAfterMillis = 1_000
|
||||
) { previewPositionMs ->
|
||||
if (!playerControlsVisible) {
|
||||
if (controlsVisible) {
|
||||
HiddenSeekTimeline(
|
||||
positionMs = previewPositionMs,
|
||||
durationMs = uiState.durationMs,
|
||||
@@ -225,14 +257,13 @@ fun PlayerScreen(
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = playerControlsVisible,
|
||||
visible = controlsVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
PlayerControlsOverlay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
uiState = uiState,
|
||||
showControls = controlsVisible,
|
||||
overlayController = overlayController,
|
||||
onBack = onBack,
|
||||
onPlayPause = { viewModel.togglePlayPause() },
|
||||
@@ -243,13 +274,12 @@ fun PlayerScreen(
|
||||
onNext = { viewModel.next() },
|
||||
onPrevious = { viewModel.previous() },
|
||||
onSelectTrack = { viewModel.selectTrack(it) },
|
||||
onQueueSelected = { viewModel.playQueueItem(it) },
|
||||
onOpenQueue = { showQueuePanel = true }
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !playerControlsVisible && uiState.activeSkippableSegmentEndMs != null,
|
||||
visible = showSkipIntroButton,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
@@ -262,11 +292,7 @@ fun PlayerScreen(
|
||||
SkipSegmentButton(onClick = { viewModel.skipActiveSegment() })
|
||||
}
|
||||
|
||||
val showNextEpisodeOverlay = !playerControlsVisible
|
||||
&& uiState.nextEpisode != null
|
||||
&& uiState.durationMs > 0L
|
||||
&& (uiState.durationMs - uiState.positionMs) <= 60_000L
|
||||
&& !uiState.isEnded
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showNextEpisodeOverlay,
|
||||
enter = fadeIn(),
|
||||
|
||||
@@ -32,10 +32,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -51,7 +47,6 @@ import hu.bbara.purefin.ui.common.button.PurefinIconButton
|
||||
@Composable
|
||||
fun PlayerControlsOverlay(
|
||||
uiState: PlayerUiState,
|
||||
showControls: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onBack: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -62,11 +57,9 @@ fun PlayerControlsOverlay(
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
onOpenQueue: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var scrubbing by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -98,11 +91,8 @@ fun PlayerControlsOverlay(
|
||||
)
|
||||
BottomSection(
|
||||
uiState = uiState,
|
||||
scrubbing = scrubbing,
|
||||
overlayController = overlayController,
|
||||
onScrubStart = { scrubbing = true },
|
||||
onScrub = onSeek,
|
||||
onScrubFinished = { scrubbing = false },
|
||||
onNext = onNext,
|
||||
onPrevious = onPrevious,
|
||||
onPlayPause = onPlayPause,
|
||||
@@ -111,7 +101,6 @@ fun PlayerControlsOverlay(
|
||||
onSeekLiveEdge = onSeekLiveEdge,
|
||||
onSkipSegment = onSkipSegment,
|
||||
onSelectTrack = onSelectTrack,
|
||||
onQueueSelected = onQueueSelected,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
@@ -163,11 +152,8 @@ private fun TopBar(
|
||||
@Composable
|
||||
private fun BottomSection(
|
||||
uiState: PlayerUiState,
|
||||
scrubbing: Boolean,
|
||||
overlayController: PersistentOverlayController,
|
||||
onScrubStart: () -> Unit,
|
||||
onScrub: (Long) -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onPrevious: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -176,7 +162,6 @@ private fun BottomSection(
|
||||
onSeekLiveEdge: () -> Unit,
|
||||
onSkipSegment: () -> Unit,
|
||||
onSelectTrack: (TrackOption) -> Unit,
|
||||
onQueueSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
@@ -194,8 +179,6 @@ private fun BottomSection(
|
||||
chapterMarkers = uiState.chapters,
|
||||
adMarkers = uiState.ads,
|
||||
onSeek = onScrub,
|
||||
onScrubStarted = onScrubStart,
|
||||
onScrubFinished = onScrubFinished,
|
||||
modifier = Modifier.testTag(PlayerSeekBarTag)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -27,8 +27,6 @@ fun PlayerSeekBar(
|
||||
chapterMarkers: List<TimedMarker>,
|
||||
adMarkers: List<TimedMarker>,
|
||||
onSeek: (Long) -> Unit,
|
||||
onScrubStarted: () -> Unit,
|
||||
onScrubFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val safeDuration = durationMs.takeIf { it > 0 } ?: 1L
|
||||
@@ -61,7 +59,6 @@ fun PlayerSeekBar(
|
||||
onValueChange = { newValue ->
|
||||
if (!isScrubbing) {
|
||||
isScrubbing = true
|
||||
onScrubStarted()
|
||||
}
|
||||
sliderPosition = newValue
|
||||
},
|
||||
@@ -69,7 +66,6 @@ fun PlayerSeekBar(
|
||||
val targetPosition = sliderPosition.toLong().coerceIn(0L, safeDuration)
|
||||
isScrubbing = false
|
||||
onSeek(targetPosition)
|
||||
onScrubFinished()
|
||||
},
|
||||
valueRange = 0f..safeDuration.toFloat(),
|
||||
colors = SliderDefaults.colors(
|
||||
|
||||
Reference in New Issue
Block a user