Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HomeRoute> { androidx.compose.material3.Text("Home content") }
composable<SubscriptionsRoute> {
androidx.compose.material3.Text("Subscriptions content")
}
composable<ChannelRoute> {
androidx.compose.material3.Text("Channel content")
}
composable<LibraryRoute> { 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 {
Expand Down
12 changes: 6 additions & 6 deletions app/src/main/java/dev/typetype/android/AppNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ internal fun AppTopBar(
internal fun AppBottomBar(
currentDestination: NavDestination?,
fallbackTabRouteQualifiedName: String?,
onTabClick: (Any) -> Unit,
onTabClick: (TopLevelTab) -> Unit,
tabs: List<TopLevelTab> = topLevelTabs,
modifier: Modifier = Modifier,
) {
Expand All @@ -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)) },
)
Expand All @@ -171,7 +171,7 @@ internal fun AppBottomBar(
internal fun AppNavigationRail(
currentDestination: NavDestination?,
fallbackTabRouteQualifiedName: String?,
onTabClick: (Any) -> Unit,
onTabClick: (TopLevelTab) -> Unit,
tabs: List<TopLevelTab> = topLevelTabs,
modifier: Modifier = Modifier,
) {
Expand All @@ -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)) },
)
Expand Down
19 changes: 16 additions & 3 deletions app/src/main/java/dev/typetype/android/AppShell.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ fun AppShell(
AppNavigationRail(
currentDestination = currentDestination,
fallbackTabRouteQualifiedName = activeTabRoute,
onTabClick = navController::navigateTopLevel,
onTabClick = { tab: TopLevelTab ->
navController.navigateTopLevel(tab.route, activeTabRoute)
},
tabs = navigationTabs,
)
}
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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<SearchRoute>() == true) {
popBackStack()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +46,7 @@ fun PlayerControls(
isPipAvailable: Boolean = false,
chaptersAvailable: Boolean = false,
sponsorBlockSegments: List<SponsorBlockSegment> = emptyList(),
seekPreviewPositionMs: Long? = null,
) {
BoxWithConstraints(modifier = modifier) {
val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT
Expand Down Expand Up @@ -91,20 +91,15 @@ fun PlayerControls(
BottomBar(
player = player,
sponsorBlockSegments = sponsorBlockSegments,
seekPreviewPositionMs = seekPreviewPositionMs,
isFullscreen = isFullscreen,
compact = compactControls,
onToggleFullscreen = onToggleFullscreen,
modifier = Modifier
.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,
Expand Down Expand Up @@ -146,16 +141,12 @@ private fun BottomScrim(compact: Boolean, modifier: Modifier = Modifier) {
private fun BottomBar(
player: Player,
sponsorBlockSegments: List<SponsorBlockSegment>,
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(
Expand All @@ -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),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -252,7 +253,7 @@ internal fun PlayerSurfaceBox(
fraction
},
onGestureFeedback = {
controlsVisible = false
controlsVisible = gestureState.dragMode.value == DragMode.Seek
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
},
isFullscreen = isFullscreen,
Expand Down Expand Up @@ -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(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,15 @@ fun PlayerTimeBar(
modifier: Modifier = Modifier,
segments: List<SponsorBlockSegment> = emptyList(),
compact: Boolean = false,
previewPositionMs: Long? = null,
) {
val progressState = rememberProgressStateWithTickInterval(player, TICK_INTERVAL_MS)
var scrubPositionMs by remember { mutableStateOf<Long?>(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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -209,7 +212,7 @@ internal fun TimelineTrack(
)
}
drawRoundRect(
color = activeColor,
color = Color.White,
topLeft = Offset(
x = playerTimeBarThumbStartX(
progressX = progressX,
Expand Down