From fd5ea5fb5c49957ec061015814b7da73b44e34ed Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 13:00:07 +0200 Subject: [PATCH 1/3] feat: control the original seekbar during gestures --- .../player/components/PlayerControls.kt | 19 ++--- .../player/components/PlayerGestureLayer.kt | 1 - .../components/PlayerGestureOverlays.kt | 75 ------------------- .../player/components/PlayerSurfaceBox.kt | 5 +- .../player/components/PlayerTimeBar.kt | 9 ++- 5 files changed, 15 insertions(+), 94 deletions(-) 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 cd44edfc..123a7b55 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 @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -47,6 +46,7 @@ fun PlayerControls( isPipAvailable: Boolean = false, chaptersAvailable: Boolean = false, sponsorBlockSegments: List = emptyList(), + seekPreviewPositionMs: Long? = null, ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT @@ -91,6 +91,7 @@ fun PlayerControls( BottomBar( player = player, sponsorBlockSegments = sponsorBlockSegments, + seekPreviewPositionMs = seekPreviewPositionMs, isFullscreen = isFullscreen, compact = compactControls, onToggleFullscreen = onToggleFullscreen, @@ -98,13 +99,7 @@ fun PlayerControls( .align(Alignment.BottomCenter) .fillMaxWidth() .testTag(PLAYER_BOTTOM_CONTROLS_TAG) - .then( - if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) - } else { - Modifier - }, - ) + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) .padding( start = if (isFullscreen) 12.dp else 4.dp, end = if (isFullscreen) 8.dp else 4.dp, @@ -146,16 +141,12 @@ private fun BottomScrim(compact: Boolean, modifier: Modifier = Modifier) { private fun BottomBar( player: Player, sponsorBlockSegments: List, + seekPreviewPositionMs: Long?, isFullscreen: Boolean, compact: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { - val backgroundModifier = if (isFullscreen) { - Modifier.background(Color.Black.copy(alpha = 0.34f), RoundedCornerShape(14.dp)) - } else { - Modifier - } Row( modifier = modifier .height( @@ -165,13 +156,13 @@ private fun BottomBar( else -> 40.dp }, ) - .then(backgroundModifier) .padding(start = if (isFullscreen) 8.dp else 2.dp, end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayerTimeBar( player = player, segments = sponsorBlockSegments, + previewPositionMs = seekPreviewPositionMs, compact = !isFullscreen, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt index 3d916c0f..561a8f84 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt @@ -234,7 +234,6 @@ fun PlayerGestureLayer( icon = volumeLevelIcon(state.volumeFraction.floatValue), modifier = Modifier.align(Alignment.Center), ) - SeekDragOverlay(state = state, durationMs = player.duration) SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = LONG_PRESS_SPEED_FACTOR) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt index c0670290..b675c9db 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt @@ -5,15 +5,11 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -134,38 +130,6 @@ private fun SeekTapWave( } } -@Composable -internal fun SeekDragOverlay(state: PlayerGestureState, durationMs: Long) { - if (!state.seekDragOverlayActive.value) return - val target = state.seekDragTargetMs.longValue - val delta = target - state.seekDragStartMs.longValue - val sign = if (delta >= 0) "+" else "-" - val deltaSec = abs(delta) / 1000 - val targetText = formatTimeMs(target) - val durationText = if (durationMs > 0) " / ${formatTimeMs(durationMs)}" else "" - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - modifier = Modifier - .width(288.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black.copy(alpha = 0.72f)) - .padding(horizontal = 18.dp, vertical = 14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = "${sign}${deltaSec}s ($targetText$durationText)", - color = Color.White, - style = MaterialTheme.typography.titleMedium, - ) - SeekWave( - fraction = if (durationMs > 0L) target / durationMs.toFloat() else 0f, - modifier = Modifier.fillMaxWidth().height(32.dp), - ) - } - } -} - @Composable internal fun SpeedBoostBadge(visible: Boolean, factor: Float) { if (!visible) return @@ -186,42 +150,3 @@ internal fun SpeedBoostBadge(visible: Boolean, factor: Float) { } } } - -@Composable -private fun SeekWave( - fraction: Float, - modifier: Modifier = Modifier, -) { - val activeColor = MaterialTheme.colorScheme.primary - Canvas(modifier = modifier) { - val centerY = size.height / 2f - val progressX = size.width * fraction.coerceIn(0f, 1f) - val bars = 32 - val spacing = size.width / bars - repeat(bars) { index -> - val x = spacing * (index + 0.5f) - val amplitude = 0.2f + abs(sin(index * 0.82f)) * 0.8f - val halfHeight = size.height * amplitude * 0.42f - drawLine( - color = if (x <= progressX) activeColor else Color.White.copy(alpha = 0.3f), - start = Offset(x, centerY - halfHeight), - end = Offset(x, centerY + halfHeight), - strokeWidth = if (x <= progressX) 3.dp.toPx() else 2.dp.toPx(), - cap = StrokeCap.Round, - ) - } - drawCircle( - color = activeColor, - radius = 4.dp.toPx(), - center = Offset(progressX, centerY), - ) - } -} - -private fun formatTimeMs(ms: Long): String { - val totalSec = ms / 1000 - val h = totalSec / 3600 - val m = (totalSec % 3600) / 60 - val s = totalSec % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s) -} 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 5257ffd3..3811d3bb 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 @@ -39,6 +39,7 @@ import dev.typetype.android.feature.player.PlayerDanmakuState import dev.typetype.android.feature.player.SponsorBlockPlaybackPolicy import dev.typetype.android.feature.player.key import dev.typetype.android.feature.player.state.PlayerGestureState +import dev.typetype.android.feature.player.state.DragMode import dev.typetype.android.feature.player.state.ResizeMode import dev.typetype.android.feature.player.state.next import kotlinx.coroutines.delay @@ -252,7 +253,7 @@ internal fun PlayerSurfaceBox( fraction }, onGestureFeedback = { - controlsVisible = false + controlsVisible = gestureState.dragMode.value == DragMode.Seek hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) }, isFullscreen = isFullscreen, @@ -299,6 +300,8 @@ internal fun PlayerSurfaceBox( isPipAvailable = isPipAvailable, chaptersAvailable = chapters.isNotEmpty(), sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, + seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue + .takeIf { gestureState.seekDragOverlayActive.value }, modifier = chromeModifier.fillMaxSize(), ) } 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 29a2b29a..7676082b 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 @@ -62,12 +62,15 @@ fun PlayerTimeBar( modifier: Modifier = Modifier, segments: List = emptyList(), compact: Boolean = false, + previewPositionMs: Long? = null, ) { val progressState = rememberProgressStateWithTickInterval(player, TICK_INTERVAL_MS) var scrubPositionMs by remember { mutableStateOf(null) } val durationMs = progressState.durationMs.coerceAtLeast(0L) - val displayedPosMs = scrubPositionMs ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val displayedPosMs = scrubPositionMs + ?: previewPositionMs?.takeIf { durationMs > 0L } + ?: progressState.currentPositionMs.coerceIn(0L, durationMs) val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) @@ -136,7 +139,7 @@ internal fun TimelineTrack( modifier: Modifier = Modifier, ) { val activeColor = MaterialTheme.colorScheme.primary - val inactiveColor = Color.White.copy(alpha = 0.3f) + val inactiveColor = Color.Black.copy(alpha = 0.38f) Box( modifier = modifier .semantics { @@ -209,7 +212,7 @@ internal fun TimelineTrack( ) } drawRoundRect( - color = activeColor, + color = Color.White, topLeft = Offset( x = playerTimeBarThumbStartX( progressX = progressX, From 6c5e1277dfe7af327a533b39d5c59e3c4cc1c404 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 13:00:07 +0200 Subject: [PATCH 2/3] fix: return to the active tab root on reselection --- .../typetype/android/AppShellAdaptiveTest.kt | 48 +++++++++++++++++++ .../dev/typetype/android/AppNavigation.kt | 12 ++--- .../java/dev/typetype/android/AppShell.kt | 19 ++++++-- 3 files changed, 70 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt index 6970ab49..7626d55d 100644 --- a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt @@ -41,8 +41,10 @@ import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.navigation.NavHostController import androidx.test.espresso.Espresso.closeSoftKeyboard import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.ChannelRoute import dev.typetype.android.core.ui.navigation.LibraryRoute import dev.typetype.android.core.ui.navigation.SearchRoute import dev.typetype.android.core.ui.navigation.SubscriptionsRoute @@ -174,6 +176,52 @@ class AppShellAdaptiveTest { composeRule.onNodeWithText("Search content").assertDoesNotExist() } + @Test + fun selectingActiveTabReturnsToTheOriginalTabPage() { + var navController: NavHostController? = null + composeRule.setContent { + val controller = rememberNavController() + navController = controller + AppShell( + navController = controller, + playerHostController = PlayerHostController(FakePlaybackQueueController()), + onOpenSearch = {}, + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + ) { contentModifier -> + NavHost( + navController = controller, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { androidx.compose.material3.Text("Home content") } + composable { + androidx.compose.material3.Text("Subscriptions content") + } + composable { + androidx.compose.material3.Text("Channel content") + } + composable { androidx.compose.material3.Text("Library content") } + } + } + } + + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + + composeRule.runOnIdle { + navController?.navigateToChannel("https://example.com/channel") + } + composeRule.onNodeWithText("Channel content").assertIsDisplayed() + + composeRule.onNodeWithText("Subscriptions").performClick() + composeRule.onNodeWithText("Subscriptions content").assertIsDisplayed() + composeRule.onNodeWithText("Channel content").assertDoesNotExist() + } + @Test fun closedKeyboardDoesNotTrapSearchNavigationOrBack() { composeRule.setContent { diff --git a/app/src/main/java/dev/typetype/android/AppNavigation.kt b/app/src/main/java/dev/typetype/android/AppNavigation.kt index a6244a30..fa17289a 100644 --- a/app/src/main/java/dev/typetype/android/AppNavigation.kt +++ b/app/src/main/java/dev/typetype/android/AppNavigation.kt @@ -144,7 +144,7 @@ internal fun AppTopBar( internal fun AppBottomBar( currentDestination: NavDestination?, fallbackTabRouteQualifiedName: String?, - onTabClick: (Any) -> Unit, + onTabClick: (TopLevelTab) -> Unit, tabs: List = topLevelTabs, modifier: Modifier = Modifier, ) { @@ -154,11 +154,11 @@ internal fun AppBottomBar( modifier = Modifier.testTag(APP_BOTTOM_NAVIGATION_TAG), containerColor = MaterialTheme.colorScheme.surface, ) { - tabs.forEach { tab -> + tabs.forEach { tab: TopLevelTab -> val selected = tab.isSelected(currentDestination, fallbackTabRouteQualifiedName, tabs) NavigationBarItem( selected = selected, - onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab.route) }, + onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab) }, icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, label = { Text(stringResource(tab.labelRes)) }, ) @@ -171,7 +171,7 @@ internal fun AppBottomBar( internal fun AppNavigationRail( currentDestination: NavDestination?, fallbackTabRouteQualifiedName: String?, - onTabClick: (Any) -> Unit, + onTabClick: (TopLevelTab) -> Unit, tabs: List = topLevelTabs, modifier: Modifier = Modifier, ) { @@ -183,11 +183,11 @@ internal fun AppNavigationRail( containerColor = MaterialTheme.colorScheme.surface, ) { Spacer(Modifier.weight(1f)) - tabs.forEach { tab -> + tabs.forEach { tab: TopLevelTab -> val selected = tab.isSelected(currentDestination, fallbackTabRouteQualifiedName, tabs) NavigationRailItem( selected = selected, - onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab.route) }, + onClick = { if (!currentDestination.matchesRoute(tab.route)) onTabClick(tab) }, icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, label = { Text(stringResource(tab.labelRes)) }, ) diff --git a/app/src/main/java/dev/typetype/android/AppShell.kt b/app/src/main/java/dev/typetype/android/AppShell.kt index d78af414..5c31247a 100644 --- a/app/src/main/java/dev/typetype/android/AppShell.kt +++ b/app/src/main/java/dev/typetype/android/AppShell.kt @@ -121,7 +121,9 @@ fun AppShell( AppNavigationRail( currentDestination = currentDestination, fallbackTabRouteQualifiedName = activeTabRoute, - onTabClick = navController::navigateTopLevel, + onTabClick = { tab: TopLevelTab -> + navController.navigateTopLevel(tab.route, activeTabRoute) + }, tabs = navigationTabs, ) } @@ -163,7 +165,9 @@ fun AppShell( AppBottomBar( currentDestination = currentDestination, fallbackTabRouteQualifiedName = activeTabRoute, - onTabClick = navController::navigateTopLevel, + onTabClick = { tab: TopLevelTab -> + navController.navigateTopLevel(tab.route, activeTabRoute) + }, tabs = navigationTabs, modifier = Modifier.playerChrome(phoneChromeAlpha), ) @@ -234,7 +238,16 @@ internal fun playerPhoneChromeAlpha( else -> 0f } -private fun NavHostController.navigateTopLevel(route: Any) { +private fun NavHostController.navigateTopLevel( + route: Any, + activeTabRouteQualifiedName: String?, +) { + val currentDestination = currentDestination + if (currentDestination.matchesRoute(route)) return + + val isSameTab = activeTabRouteQualifiedName == route::class.qualifiedName + if (isSameTab && popBackStack(route = route, inclusive = false, saveState = true)) return + if (currentDestination?.hasRoute() == true) { popBackStack() } From 3da80a982922cc578afbd930adf60ed3ebc78e37 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 13:00:47 +0200 Subject: [PATCH 3/3] chore: bump beta to 1.7.0-beta.8 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f4a361d..2ba0d5df 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 = 10706 - versionName = "1.7.0-beta.7" + versionCode = 10707 + versionName = "1.7.0-beta.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") }