diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a1263266..010c0901 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10709 - versionName = "1.7.0-beta.10" + versionCode = 10710 + versionName = "1.7.0-beta.11" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt index c0b698f5..9106a6fc 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/LoadedPlayer.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.derivedStateOf @@ -189,8 +188,6 @@ fun LoadedPlayer( onSaveProgress = { onAction(PlayerAction.OnSaveProgress(it)) }, ) - val expandedTopPadding = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - val expandedTopPaddingPx = with(LocalDensity.current) { expandedTopPadding.toPx() } val autoplayVisible by remember(hostTransitionProgress) { derivedStateOf { hostTransitionProgress() < 0.01f } } @@ -198,16 +195,6 @@ fun LoadedPlayer( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .then( - if (isFullscreen) { - Modifier - } else { - Modifier.playerTopProgressPadding( - maxTopPx = expandedTopPaddingPx, - progress = hostTransitionProgress, - ) - } - ), ) { PlayerContentLayout( isFullscreen = isFullscreen, @@ -322,23 +309,3 @@ fun LoadedPlayer( onAction = onAction, ) } - -private fun Modifier.playerTopProgressPadding( - maxTopPx: Float, - progress: () -> Float, -): Modifier = layout { measurable, constraints -> - val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() - val maxHeight = if (constraints.hasBoundedHeight) { - (constraints.maxHeight - topPx).coerceAtLeast(0) - } else { - constraints.maxHeight - } - val childConstraints = constraints.copy( - minHeight = constraints.minHeight.coerceAtMost(maxHeight), - maxHeight = maxHeight, - ) - val placeable = measurable.measure(childConstraints) - layout(placeable.width, placeable.height + topPx) { - placeable.placeRelative(0, topPx) - } -} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index f65401e4..5bd834ca 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -47,53 +47,59 @@ fun PlayerControls( chaptersAvailable: Boolean = false, sponsorBlockSegments: List = emptyList(), seekPreviewPositionMs: Long? = null, + timelineScrubbing: Boolean = false, onTimelineScrubbingChange: (Boolean) -> Unit = {}, ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT - TopScrim( - compact = compactControls, - modifier = Modifier.align(Alignment.TopCenter), - ) + if (!timelineScrubbing) { + TopScrim( + compact = compactControls, + modifier = Modifier.align(Alignment.TopCenter), + ) + } BottomScrim( compact = compactControls, isFullscreen = isFullscreen, modifier = Modifier.align(Alignment.BottomCenter), ) - PlayerTopBar( - title = title, - onNavigateBack = onNavigateBack, - onOpenChapters = onOpenChapters, - onOpenOptions = onOpenOptions, - onEnterPip = onEnterPip, - onCycleResizeMode = onCycleResizeMode, - resizeMode = resizeMode, - isFullscreen = isFullscreen, - isPipAvailable = isPipAvailable, - chaptersAvailable = chaptersAvailable, - compact = compactControls, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .testTag(PLAYER_TOP_CONTROLS_TAG) - .then( - if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.statusBars) - } else { - Modifier - }, - ), - ) - PlayerCenterControls( - player = player, - isFullscreen = isFullscreen, - compact = compactControls, - modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), - ) + if (!timelineScrubbing) { + PlayerTopBar( + title = title, + onNavigateBack = onNavigateBack, + onOpenChapters = onOpenChapters, + onOpenOptions = onOpenOptions, + onEnterPip = onEnterPip, + onCycleResizeMode = onCycleResizeMode, + resizeMode = resizeMode, + isFullscreen = isFullscreen, + isPipAvailable = isPipAvailable, + chaptersAvailable = chaptersAvailable, + compact = compactControls, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .testTag(PLAYER_TOP_CONTROLS_TAG) + .then( + if (isFullscreen) { + Modifier.windowInsetsPadding(WindowInsets.statusBars) + } else { + Modifier + }, + ), + ) + PlayerCenterControls( + player = player, + isFullscreen = isFullscreen, + compact = compactControls, + modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), + ) + } BottomBar( player = player, sponsorBlockSegments = sponsorBlockSegments, seekPreviewPositionMs = seekPreviewPositionMs, + timelineScrubbing = timelineScrubbing, onTimelineScrubbingChange = onTimelineScrubbingChange, isFullscreen = isFullscreen, compact = compactControls, @@ -161,12 +167,14 @@ private fun BottomBar( player: Player, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, + timelineScrubbing: Boolean, onTimelineScrubbingChange: (Boolean) -> Unit, isFullscreen: Boolean, compact: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { + // I SPEND 2 HOURS FIGURING OUT HOW TO ALIGN THIS FREAKING SEEKBAR WITH THE BUTTON FULLSCREEN IM SUCH A FAILURE Row( modifier = modifier .height( @@ -187,23 +195,25 @@ private fun BottomBar( onScrubbingChange = onTimelineScrubbingChange, modifier = Modifier.weight(1f), ) - IconButton( - onClick = onToggleFullscreen, - modifier = Modifier.size( - when { - isFullscreen -> 48.dp - compact -> 36.dp - else -> 40.dp - }, - ), - ) { - Icon( - painter = painterResource( - if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + if (!timelineScrubbing) { + IconButton( + onClick = onToggleFullscreen, + modifier = Modifier.size( + when { + isFullscreen -> 48.dp + compact -> 36.dp + else -> 40.dp + }, ), - contentDescription = stringResource(R.string.player_fullscreen), - tint = Color.White, - ) + ) { + Icon( + painter = painterResource( + if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen, + ), + contentDescription = stringResource(R.string.player_fullscreen), + tint = Color.White, + ) + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index d5d1eef0..f79a05fe 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -9,6 +9,11 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -24,6 +29,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.unit.dp import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.ui.compose.state.rememberPresentationState @@ -262,7 +268,7 @@ internal fun PlayerSurfaceBox( fraction }, onGestureFeedback = { - controlsVisible = gestureState.dragMode.value == DragMode.Seek + controlsVisible = false hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, isFullscreen = isFullscreen, @@ -278,6 +284,31 @@ internal fun PlayerSurfaceBox( ) } + AnimatedVisibility( + visible = gestureState.seekDragOverlayActive.value, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + PlayerSeekScrubOverlay( + player = player, + positionMs = gestureState.seekDragTargetMs.longValue, + segments = sponsorBlockPolicy.visibleSegments, + isFullscreen = isFullscreen, + modifier = Modifier + .fillMaxWidth() + .then( + if (isFullscreen) { + Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(start = 4.dp, end = 4.dp) + }, + ), + ) + } + AnimatedVisibility( visible = controlsAllowedByProgress && (controlsVisible || accessibleControls) && diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index 249ab29d..028e0bda 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width @@ -54,6 +55,8 @@ private val THUMB_WIDTH = 14.dp private val THUMB_HEIGHT = 14.dp private val COMPACT_THUMB_WIDTH = 10.dp private val COMPACT_THUMB_HEIGHT = 10.dp +private val SCRUBBING_TRACK_HEIGHT = 6.dp +private val SCRUBBING_THUMB_SIZE = 22.dp @OptIn(markerClass = [UnstableApi::class]) @Composable @@ -72,6 +75,7 @@ fun PlayerTimeBar( val displayedPosMs = scrubPositionMs ?: previewPositionMs?.takeIf { durationMs > 0L } ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val emphasized = scrubPositionMs != null || previewPositionMs != null val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) @@ -96,6 +100,7 @@ fun PlayerTimeBar( durationMs = durationMs, segments = segments, compact = compact, + emphasized = emphasized, onScrub = { onScrubbingChange(true) scrubPositionMs = it @@ -139,6 +144,8 @@ internal fun TimelineTrack( durationMs: Long, segments: List, compact: Boolean, + emphasized: Boolean = false, + interactive: Boolean = true, onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, onScrubCancelled: () -> Unit, @@ -148,8 +155,8 @@ internal fun TimelineTrack( ) { val activeColor = MaterialTheme.colorScheme.primary val inactiveColor = Color.Black.copy(alpha = 0.38f) - Box( - modifier = modifier + val timelineModifier = if (interactive) { + modifier .semantics { contentDescription = accessibilityLabel stateDescription = accessibilityStateDescription @@ -185,12 +192,27 @@ internal fun TimelineTrack( onDragEnd = { onScrubFinished(lastTargetMs) }, onDragCancel = onScrubCancelled, ) - }, + } + } else { + modifier + } + Box( + modifier = timelineModifier, ) { Canvas(modifier = Modifier.fillMaxSize()) { - val trackHeight = if (compact) COMPACT_TRACK_HEIGHT.toPx() else TRACK_HEIGHT.toPx() - val thumbWidth = if (compact) COMPACT_THUMB_WIDTH.toPx() else THUMB_WIDTH.toPx() - val thumbHeight = if (compact) COMPACT_THUMB_HEIGHT.toPx() else THUMB_HEIGHT.toPx() + val targetTrackHeight = when { + emphasized -> SCRUBBING_TRACK_HEIGHT + compact -> COMPACT_TRACK_HEIGHT + else -> TRACK_HEIGHT + } + val targetThumbSize = when { + emphasized -> SCRUBBING_THUMB_SIZE + compact -> COMPACT_THUMB_WIDTH + else -> THUMB_WIDTH + } + val trackHeight = targetTrackHeight.toPx() + val thumbWidth = targetThumbSize.toPx() + val thumbHeight = targetThumbSize.toPx() val trackTop = (size.height - trackHeight) / 2f val trackRadius = trackHeight / 2f val progress = if (durationMs > 0) positionMs.toFloat() / durationMs.toFloat() else 0f @@ -245,6 +267,35 @@ internal fun playerTimeBarThumbStartX( return (progressX - thumbWidth / 2f).coerceIn(0f, maximumStartX) } +@Composable +internal fun PlayerSeekScrubOverlay( + player: Player, + positionMs: Long, + segments: List, + isFullscreen: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.height(if (isFullscreen) 52.dp else 40.dp)) { + TimelineTrack( + positionMs = positionMs, + durationMs = player.duration.coerceAtLeast(0L), + segments = segments, + compact = false, + emphasized = true, + interactive = false, + onScrub = {}, + onScrubFinished = {}, + onScrubCancelled = {}, + accessibilityLabel = "", + accessibilityStateDescription = "", + modifier = Modifier + .align(Alignment.Center) + .fillMaxSize() + .padding(horizontal = 4.dp), + ) + } +} + private fun Float.toPositionMs(width: Float, durationMs: Long): Long { if (durationMs <= 0 || width <= 0f) return 0L return ((this / width).coerceIn(0f, 1f) * durationMs).toLong()