diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 632a67d9..d2992881 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 = 10716 - versionName = "1.7.0-beta.17" + versionCode = 10801 + versionName = "1.8.0-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } @@ -50,7 +50,7 @@ android { buildTypes { getByName("release") { isMinifyEnabled = true - isShrinkResources = false + isShrinkResources = true signingConfig = signingConfigs.findByName("release")?.takeIf { it.storeFile != null } proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt index 7626d55d..b868b550 100644 --- a/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/AppShellAdaptiveTest.kt @@ -64,76 +64,6 @@ class AppShellAdaptiveTest { @get:Rule val composeRule = createAndroidComposeRule() - @Test - fun wideWindowUsesNavigationRail() { - setShellWidth(700.dp) - - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 1) - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 0) - } - - @Test - fun compactWindowUsesBottomNavigation() { - setShellWidth(400.dp) - - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) - } - - @Test - fun landscapePhoneUsesBottomNavigation() { - setShellSize(width = 800.dp, height = 400.dp) - - assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) - assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) - } - - @Test - fun shortsTabFollowsTheServerVisibilitySetting() { - val showShorts = mutableStateOf(false) - setShellSize(width = 300.dp, height = 500.dp, showShorts = showShorts) - - composeRule.onNodeWithText("Shorts").assertDoesNotExist() - - composeRule.runOnIdle { showShorts.value = true } - - composeRule.onNodeWithText("Shorts").assertIsDisplayed() - } - - @Test - fun compactNavigationRemainsVisibleAtTwoHundredPercentText() { - setShellSize(width = 320.dp, height = 500.dp, fontScale = 2f) - - listOf("Home", "Shorts", "Subscriptions", "Library").forEach { - composeRule.onNodeWithText(it).assertIsDisplayed() - } - } - - @Test - fun rightToLeftLayoutMirrorsTheTopLevelTabs() { - setShellSize( - width = 400.dp, - height = 800.dp, - layoutDirection = LayoutDirection.Rtl, - ) - - val home = composeRule.onNodeWithText("Home").bounds() - val library = composeRule.onNodeWithText("Library").bounds() - assertTrue(home.left > library.left) - } - - @Test - fun directionalKeysMoveFocusAcrossTopLevelTabs() { - setShellSize(width = 400.dp, height = 800.dp, keyboardInput = true) - val home = composeRule.onNodeWithText("Home") - - home.performSemanticsAction(SemanticsActions.RequestFocus) - home.assertIsFocused() - home.performKeyInput { pressKey(Key.DirectionRight) } - - composeRule.onNodeWithText("Shorts").assertIsFocused() - } - @Test fun topLevelNavigationDoesNotRestoreSearchOverItsOpeningTab() { composeRule.setContent { @@ -318,59 +248,6 @@ class AppShellAdaptiveTest { composeRule.onNodeWithTag(SEARCH_FIELD_TAG).assertTextEquals("") } - private fun setShellWidth(width: Dp) { - setShellSize(width = width, height = 800.dp) - } - - private fun setShellSize( - width: Dp, - height: Dp, - showShorts: MutableState = mutableStateOf(true), - fontScale: Float = 1f, - layoutDirection: LayoutDirection = LayoutDirection.Ltr, - keyboardInput: Boolean = false, - ) { - composeRule.setContent { - val systemDensity = LocalDensity.current - val inputModeManager = LocalInputModeManager.current - LaunchedEffect(keyboardInput) { - if (keyboardInput) inputModeManager.requestInputMode(InputMode.Keyboard) - } - CompositionLocalProvider( - LocalDensity provides Density(systemDensity.density, fontScale), - LocalLayoutDirection provides layoutDirection, - ) { - val navController = rememberNavController() - AppShell( - navController = navController, - playerHostController = PlayerHostController(FakePlaybackQueueController()), - onOpenSettings = {}, - onPlayVideo = {}, - onOpenChannel = {}, - onOpenAccounts = {}, - onClosePlayback = {}, - showShorts = showShorts.value, - modifier = Modifier.requiredWidth(width).requiredHeight(height), - ) { contentModifier -> - NavHost( - navController = navController, - startDestination = HomeRoute, - modifier = contentModifier, - ) { - composable { } - } - } - } - } - } - - private fun androidx.compose.ui.test.SemanticsNodeInteraction.bounds(): Rect = - fetchSemanticsNode().boundsInRoot - - private fun assertNodeCount(tag: String, expected: Int) { - val count = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size - assertEquals(expected, count) - } } private const val SEARCH_FIELD_TAG = "search_field" diff --git a/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt new file mode 100644 index 00000000..ef91c118 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/AppShellNavigationLayoutTest.kt @@ -0,0 +1,197 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +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 +import dev.typetype.android.domain.playback.PlaybackQueueController +import dev.typetype.android.domain.playback.PlaybackQueueEntry +import dev.typetype.android.domain.playback.PlaybackQueueSnapshot +import dev.typetype.android.domain.playback.PlaybackQueueState +import dev.typetype.android.feature.player.host.PlayerHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Rule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppShellNavigationLayoutTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun wideWindowUsesBottomNavigation() { + setShellWidth(700.dp) + + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + } + + @Test + fun compactWindowUsesBottomNavigation() { + setShellWidth(400.dp) + + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + } + + @Test + fun landscapePhoneUsesBottomNavigation() { + setShellSize(width = 800.dp, height = 400.dp) + + assertNodeCount(APP_BOTTOM_NAVIGATION_TAG, 1) + assertNodeCount(APP_NAVIGATION_RAIL_TAG, 0) + } + + @Test + fun shortsTabFollowsTheServerVisibilitySetting() { + val showShorts = mutableStateOf(false) + setShellSize(width = 300.dp, height = 500.dp, showShorts = showShorts) + + composeRule.onNodeWithText("Shorts").assertDoesNotExist() + + composeRule.runOnIdle { showShorts.value = true } + + composeRule.onNodeWithText("Shorts").assertIsDisplayed() + } + + @Test + fun compactNavigationRemainsVisibleAtTwoHundredPercentText() { + setShellSize(width = 320.dp, height = 500.dp, fontScale = 2f) + + listOf("Home", "Shorts", "Subscriptions", "Library").forEach { + composeRule.onNodeWithText(it).assertIsDisplayed() + } + } + + @Test + fun rightToLeftLayoutMirrorsTheTopLevelTabs() { + setShellSize( + width = 400.dp, + height = 800.dp, + layoutDirection = LayoutDirection.Rtl, + ) + + val home = composeRule.onNodeWithText("Home").bounds() + val library = composeRule.onNodeWithText("Library").bounds() + assertTrue(home.left > library.left) + } + + @Test + fun directionalKeysMoveFocusAcrossTopLevelTabs() { + setShellSize(width = 400.dp, height = 800.dp, keyboardInput = true) + val home = composeRule.onNodeWithText("Home") + + home.performSemanticsAction(SemanticsActions.RequestFocus) + home.assertIsFocused() + home.performKeyInput { pressKey(Key.DirectionRight) } + + composeRule.onNodeWithText("Shorts").assertIsFocused() + } + + private fun setShellWidth(width: Dp) { + setShellSize(width = width, height = 800.dp) + } + + private fun setShellSize( + width: Dp, + height: Dp, + showShorts: MutableState = mutableStateOf(true), + fontScale: Float = 1f, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, + keyboardInput: Boolean = false, + ) { + composeRule.setContent { + val systemDensity = LocalDensity.current + val inputModeManager = LocalInputModeManager.current + LaunchedEffect(keyboardInput) { + if (keyboardInput) inputModeManager.requestInputMode(InputMode.Keyboard) + } + CompositionLocalProvider( + LocalDensity provides Density(systemDensity.density, fontScale), + LocalLayoutDirection provides layoutDirection, + ) { + val navController = rememberNavController() + AppShell( + navController = navController, + playerHostController = PlayerHostController(TabletLayoutQueueController()), + onOpenSettings = {}, + onPlayVideo = {}, + onOpenChannel = {}, + onOpenAccounts = {}, + onClosePlayback = {}, + showShorts = showShorts.value, + modifier = Modifier.requiredWidth(width).requiredHeight(height), + ) { contentModifier -> + NavHost( + navController = navController, + startDestination = HomeRoute, + modifier = contentModifier, + ) { + composable { } + } + } + } + } + } + + private fun androidx.compose.ui.test.SemanticsNodeInteraction.bounds(): Rect = + fetchSemanticsNode().boundsInRoot + + private fun assertNodeCount(tag: String, expected: Int) { + val count = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size + assertEquals(expected, count) + } +} + +private class TabletLayoutQueueController : PlaybackQueueController { + override val state: StateFlow = MutableStateFlow(PlaybackQueueState()) + override fun start(title: String, entries: List, shuffle: Boolean) = Unit + override fun restore(snapshot: PlaybackQueueSnapshot) = Unit + override fun clear() = Unit +} diff --git a/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt b/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt new file mode 100644 index 00000000..f7f07a8b --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/SettingsNavigationTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.foundation.clickable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.typetype.android.core.ui.navigation.AppearanceRoute +import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute +import dev.typetype.android.core.ui.navigation.SettingsRoute +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class SettingsNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private lateinit var controller: NavHostController + + @Test + fun switchingCategoriesRestoresStateAndDoesNotAccumulateBackEntries() { + composeRule.setContent { + controller = rememberNavController() + NavHost(controller, startDestination = SettingsRoute) { + composable { Text("Settings index") } + composable { + var count by rememberSaveable { mutableIntStateOf(0) } + Text("Appearance $count", Modifier.clickable { count += 1 }) + } + composable { Text("Player settings") } + } + } + + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.onNodeWithText("Appearance 0").performClick() + composeRule.runOnIdle { controller.selectSettings(PlayerSettingsRoute) } + composeRule.onNodeWithText("Player settings").assertExists() + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.onNodeWithText("Appearance 1").assertExists() + composeRule.runOnIdle { controller.selectSettings(AppearanceRoute) } + composeRule.runOnIdle { assertTrue(controller.popBackStack()) } + composeRule.onNodeWithText("Settings index").assertExists() + composeRule.runOnIdle { assertFalse(controller.popBackStack()) } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt b/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt new file mode 100644 index 00000000..27885d58 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/TopLevelNavigationTest.kt @@ -0,0 +1,52 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import dev.typetype.android.core.ui.navigation.ChannelRoute +import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.LibraryRoute +import dev.typetype.android.core.ui.navigation.SubscriptionsRoute +import org.junit.Rule +import org.junit.Test + +class TopLevelNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private lateinit var controller: NavHostController + + @Test + fun changingTabsRestoresChannelButReselectingReturnsToSubscriptions() { + composeRule.setContent { + controller = rememberNavController() + NavHost(controller, startDestination = HomeRoute) { + composable { Text("Home") } + composable { Text("Subscriptions") } + composable { Text("Library") } + composable { Text("Channel") } + } + } + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, HomeRoute::class.qualifiedName) + } + composeRule.runOnIdle { controller.navigate(ChannelRoute("https://www.youtube.com/@channel")) } + composeRule.onNodeWithText("Channel").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(LibraryRoute, SubscriptionsRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Library").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, LibraryRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Channel").assertExists() + composeRule.runOnIdle { + controller.navigateTopLevel(SubscriptionsRoute, SubscriptionsRoute::class.qualifiedName) + } + composeRule.onNodeWithText("Subscriptions").assertExists() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt index bb6f7605..bf6d75e4 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerContentLayoutComposeTest.kt @@ -30,14 +30,17 @@ class PlayerContentLayoutComposeTest { val composeRule = createAndroidComposeRule() @Test - fun wideTabletPlacesDetailsBesideThePlayer() { + fun wideTabletKeepsDetailsUnderVideoAndRecommendationsBesideIt() { setLayout(width = 1280.dp, height = 800.dp) val viewport = bounds(VIEWPORT_TAG) val details = bounds(DETAILS_TAG) - assertTrue(viewport.right <= details.left) - assertTrue(details.top == viewport.top) + val recommendations = bounds(RECOMMENDATIONS_TAG) + assertTrue(viewport.bottom <= details.top) + assertEquals(viewport.left, details.left, 1f) + assertTrue(viewport.right <= recommendations.left) + assertEquals(viewport.top, recommendations.top, 1f) assertNodeCount(PLAYER_TWO_PANE_LAYOUT_TAG, 1) assertNodeCount(PLAYER_SINGLE_COLUMN_LAYOUT_TAG, 0) } @@ -143,6 +146,7 @@ class PlayerContentLayoutComposeTest { Box(Modifier.height(600.dp)) } }, + recommendations = { Box(it.testTag(RECOMMENDATIONS_TAG).height(600.dp)) }, ) } } @@ -162,3 +166,4 @@ class PlayerContentLayoutComposeTest { private const val VIEWPORT_TAG = "player_viewport" private const val DETAILS_TAG = "player_details" +private const val RECOMMENDATIONS_TAG = "player_recommendations" diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt new file mode 100644 index 00000000..a4ebd3dc --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt @@ -0,0 +1,71 @@ +package dev.typetype.android.feature.player + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerInteractionRowComposeTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tabletShowsLabelsAndLargerTouchTargets() { + var commentClicks = 0 + setActions(720.dp) { commentClicks += 1 } + + val comments = composeRule.activity.getString(R.string.comments_title) + composeRule.onNodeWithText(comments).assertIsDisplayed() + composeRule.onNodeWithContentDescription(comments) + .assertHeightIsAtLeast(72.dp) + .performClick() + assertEquals(1, commentClicks) + composeRule.onNodeWithText( + composeRule.activity.getString(R.string.video_menu_share), + ).assertIsDisplayed() + } + + @Test + fun narrowWindowRetainsCompactActions() { + setActions(400.dp) + + val comments = composeRule.activity.getString(R.string.comments_title) + composeRule.onNodeWithText(comments).assertDoesNotExist() + composeRule.onNodeWithContentDescription(comments) + .assertIsDisplayed() + .assertHeightIsAtLeast(48.dp) + } + + private fun setActions(width: Dp, onComments: () -> Unit = {}) { + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(width)) { + PlayerInteractionRow( + isFavorited = false, + isInWatchLater = false, + shareUrl = "https://www.youtube.com/watch?v=8E-cXrEgz2U", + onToggleFavorite = {}, + onToggleWatchLater = {}, + onAddToPlaylist = {}, + onShowComments = onComments, + onDownload = {}, + audioOnlyAvailable = true, + ) + } + } + } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index ac6b4c7e..dc92b42e 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -9,6 +9,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.onNodeWithContentDescription +import dev.typetype.android.R import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -29,11 +33,30 @@ class PlayerControlsLayoutTest { @Test fun portraitControlsDoNotOverlapInsideShortVideoViewport() { + setControls(360.dp, 202.dp) + assertControlsDoNotOverlap() + } + + @Test + fun tabletControlsHaveLargerTargetsWithoutOverlapping() { + setControls(720.dp, 405.dp) + assertControlsDoNotOverlap() + composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) + .assertHeightIsAtLeast(96.dp) + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_fullscreen), + ).assertHeightIsAtLeast(64.dp) + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_playback_options), + ).assertHeightIsAtLeast(64.dp) + } + + private fun setControls(width: Dp, height: Dp) { val player = controlsLayoutPlayer() composeRule.setContent { Box( Modifier - .size(width = 360.dp, height = 202.dp) + .size(width = width, height = height) .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), ) { PlayerControls( @@ -46,7 +69,9 @@ class PlayerControlsLayoutTest { ) } } + } + private fun assertControlsDoNotOverlap() { val top = composeRule.onNodeWithTag(PLAYER_TOP_CONTROLS_TAG, useUnmergedTree = true) .fetchSemanticsNode() .boundsInRoot diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt index 4a7a7d9a..f0e0e235 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -20,6 +21,7 @@ import androidx.compose.ui.test.swipe import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.PlaybackParameters import androidx.media3.common.SimpleBasePlayer import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.util.concurrent.Futures @@ -37,6 +39,73 @@ class PlayerGestureLayerTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun longPressDragChangesSpeedWithoutChangingLevelsAndRestoresOnRelease() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val levelChanges = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = { levelChanges.incrementAndGet() }, + onAdjustVolume = { levelChanges.incrementAndGet() }, + isFullscreen = true, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.runOnIdle { player.setPlaybackSpeed(1.5f) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(center) + advanceEventTime(700) + moveTo(center) + } + composeRule.runOnIdle { assertEquals(2f, player.playbackParameters.speed) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(0f, -height * 0.3f)) + } + composeRule.runOnIdle { + assertTrue(player.playbackParameters.speed > 2f) + assertEquals(0, levelChanges.get()) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { up() } + composeRule.runOnIdle { + assertEquals(1.5f, player.playbackParameters.speed) + player.release() + } + } + + @Test + fun removingGestureLayerDuringHoldRestoresPreviousSpeed() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val visible = mutableStateOf(true) + composeRule.setContent { + if (visible.value) PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.runOnIdle { player.setPlaybackSpeed(1.25f) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(center) + advanceEventTime(700) + moveTo(center) + } + composeRule.runOnIdle { + assertEquals(2f, player.playbackParameters.speed) + visible.value = false + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertEquals(1.25f, player.playbackParameters.speed) + player.release() + } + } + @Test fun doubleTapZonesSeekTogglePlaybackAndProvideFeedback() { val player = GestureTestPlayer(Looper.getMainLooper()) @@ -164,10 +233,12 @@ class PlayerGestureLayerTest { private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { private var positionMs = 20_000L private var playWhenReady = false + private var parameters = PlaybackParameters.DEFAULT override fun getState(): State = State.Builder() .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) .setPlaybackState(Player.STATE_READY) + .setPlaybackParameters(parameters) .setPlayWhenReady( playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, @@ -175,6 +246,7 @@ private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { .setPlaylist( listOf( MediaItemData.Builder("gesture-item") + .setDurationUs(600_000_000L) .setMediaItem(MediaItem.Builder().setMediaId("gesture-item").build()) .build(), ), @@ -199,4 +271,10 @@ private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { } override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() + + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + parameters = playbackParameters + invalidateState() + return Futures.immediateVoidFuture() + } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt index 7476ab3d..95650e06 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/podcast/PodcastScreenStateTest.kt @@ -1,6 +1,12 @@ package dev.typetype.android.feature.podcast import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -11,6 +17,7 @@ import dev.typetype.android.domain.podcast.Podcast import dev.typetype.android.feature.menu.VideoMenuScope import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertTrue class PodcastScreenStateTest { @get:Rule @@ -37,8 +44,32 @@ class PodcastScreenStateTest { @Test fun emptyPodcastHasAnExplicitState() { + showEmptyPodcast(400.dp) + composeRule.onNodeWithText("This podcast has no available episodes.").assertIsDisplayed() + } + + @Test + fun tabletKeepsPodcastIdentityBesideEpisodes() { + showEmptyPodcast(1200.dp) + val title = composeRule.onNodeWithText("Podcast").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This podcast has no available episodes.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.right < content.left) + } + + @Test + fun narrowWindowKeepsPodcastIdentityAboveEpisodes() { + showEmptyPodcast(400.dp) + val title = composeRule.onNodeWithText("Podcast").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This podcast has no available episodes.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.bottom < content.top) + } + + private fun showEmptyPodcast(width: Dp) { composeRule.setContent { TypeTypeTheme { + Box(Modifier.requiredWidth(width).requiredHeight(650.dp)) { PodcastContentGrid( state = PodcastState(isLoading = false, podcast = podcast()), onPlayVideo = {}, @@ -47,9 +78,9 @@ class PodcastScreenStateTest { onLoadMore = {}, menuScope = emptyMenuScope(), ) + } } } - composeRule.onNodeWithText("This podcast has no available episodes.").assertIsDisplayed() } private fun showScreen(state: PodcastState) { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt index 1dea65e7..80befb27 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreenStateTest.kt @@ -1,6 +1,12 @@ package dev.typetype.android.feature.publicplaylist import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription @@ -11,6 +17,7 @@ import dev.typetype.android.domain.search.SearchPlaylist import dev.typetype.android.feature.menu.VideoMenuScope import org.junit.Rule import org.junit.Test +import org.junit.Assert.assertTrue class PublicPlaylistScreenStateTest { @get:Rule @@ -37,8 +44,32 @@ class PublicPlaylistScreenStateTest { @Test fun emptyPlaylistHasAnExplicitState() { + showEmptyPlaylist(400.dp) + composeRule.onNodeWithText("This playlist has no available videos.").assertIsDisplayed() + } + + @Test + fun tabletKeepsPlaylistIdentityBesideItsContent() { + showEmptyPlaylist(1200.dp) + val title = composeRule.onNodeWithText("Playlist").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This playlist has no available videos.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.right < content.left) + } + + @Test + fun narrowWindowKeepsPlaylistIdentityAboveItsContent() { + showEmptyPlaylist(400.dp) + val title = composeRule.onNodeWithText("Playlist").fetchSemanticsNode().boundsInRoot + val content = composeRule.onNodeWithText("This playlist has no available videos.") + .fetchSemanticsNode().boundsInRoot + assertTrue(title.bottom < content.top) + } + + private fun showEmptyPlaylist(width: Dp) { composeRule.setContent { TypeTypeTheme { + Box(Modifier.requiredWidth(width).requiredHeight(650.dp)) { PublicPlaylistContentGrid( state = PublicPlaylistState(isLoading = false, playlist = playlist()), onPlayVideo = {}, @@ -48,9 +79,9 @@ class PublicPlaylistScreenStateTest { onToggleSaved = {}, menuScope = emptyMenuScope(), ) + } } } - composeRule.onNodeWithText("This playlist has no available videos.").assertIsDisplayed() } private fun showScreen(state: PublicPlaylistState) { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt index 3b3447ad..9a6e5155 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/SettingsScreenAccessibilityTest.kt @@ -139,7 +139,6 @@ class SettingsScreenAccessibilityTest { R.string.accounts_title, R.string.settings_profile_title, R.string.settings_import_title, - R.string.youtube_session_settings_title, R.string.rss_settings_title, R.string.settings_appearance_title, R.string.settings_content_title, diff --git a/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt new file mode 100644 index 00000000..6f0d9c68 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/AdaptiveSettingsHost.kt @@ -0,0 +1,113 @@ +package dev.typetype.android + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.AccountsRoute +import dev.typetype.android.core.ui.navigation.AppearanceRoute +import dev.typetype.android.core.ui.navigation.BlockedSettingsRoute +import dev.typetype.android.core.ui.navigation.ContentSettingsRoute +import dev.typetype.android.core.ui.navigation.DiagnosticsRoute +import dev.typetype.android.core.ui.navigation.ImportDataRoute +import dev.typetype.android.core.ui.navigation.LicensesRoute +import dev.typetype.android.core.ui.navigation.PlayerSettingsRoute +import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute +import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute +import dev.typetype.android.core.ui.navigation.RssFeedsRoute +import dev.typetype.android.core.ui.navigation.SettingsRoute +import dev.typetype.android.core.ui.navigation.StorageSettingsRoute +import dev.typetype.android.feature.settings.SettingsRoute as SettingsMenu + +internal val LocalSettingsTwoPane = staticCompositionLocalOf { false } + +@Composable +internal fun AdaptiveSettingsHost( + navController: NavHostController, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val entry by navController.currentBackStackEntryAsState() + val selectedTitle = entry?.destination?.settingsTitle() + BoxWithConstraints(modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp && selectedTitle != null + CompositionLocalProvider(LocalSettingsTwoPane provides twoPane) { + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Box(Modifier.width(300.dp)) { + SettingsMenu( + selectedTitleRes = selectedTitle, + onNavigateBack = { + if (entry?.destination?.hasRoute() == true) { + navController.popBackStack() + } else if (navController.popBackStack(false)) { + navController.popBackStack() + } else { + navController.popBackStack() + } + }, + onOpenAccounts = { navController.selectSettings(AccountsRoute) }, + onOpenProfile = { navController.selectSettings(ProfileSettingsRoute) }, + onOpenImport = { navController.selectSettings(ImportDataRoute) }, + onOpenRssFeeds = { navController.selectSettings(RssFeedsRoute) }, + onOpenAppearance = { navController.selectSettings(AppearanceRoute) }, + onOpenContent = { navController.selectSettings(ContentSettingsRoute) }, + onOpenPlayer = { navController.selectSettings(PlayerSettingsRoute) }, + onOpenStorage = { navController.selectSettings(StorageSettingsRoute) }, + onOpenPrivacy = { navController.selectSettings(PrivacySettingsRoute) }, + onOpenDiagnostics = { navController.selectSettings(DiagnosticsRoute) }, + onOpenBlocked = { navController.selectSettings(BlockedSettingsRoute) }, + onOpenAbout = { navController.selectSettings(AboutRoute) }, + onSignOut = onSignOut, + ) + } + VerticalDivider() + } + Box(Modifier.weight(1f).fillMaxSize()) { content() } + } + } + } +} + +internal fun NavHostController.selectSettings(route: Any) { + val current = currentDestination ?: return + if (current.hasRoute(route::class)) return + navigate(route) { + popUpTo(current.id) { + inclusive = !current.hasRoute() + saveState = true + } + launchSingleTop = true + restoreState = true + } +} + +private fun NavDestination.settingsTitle(): Int? = when { + hasRoute() || hasRoute() -> R.string.settings_appearance_title + hasRoute() -> R.string.accounts_title + hasRoute() -> R.string.settings_profile_title + hasRoute() -> R.string.settings_import_title + hasRoute() -> R.string.rss_settings_title + hasRoute() -> R.string.settings_content_title + hasRoute() -> R.string.settings_player_title + hasRoute() -> R.string.settings_storage_title + hasRoute() -> R.string.settings_privacy_title + hasRoute() -> R.string.diagnostics_title + hasRoute() -> R.string.settings_blocked_title + hasRoute() || hasRoute() -> R.string.settings_about_title + else -> null +} diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index b8cfdcba..b43f93d3 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -152,10 +152,14 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { onOpenChannel = onOpenChannel, onClosePlayback = mainViewModel::closePlayback, ) { innerModifier -> + AdaptiveSettingsHost( + navController = navController, + onSignOut = mainViewModel::signOut, + modifier = innerModifier, + ) { NavHost( navController = navController, startDestination = startRoute, - modifier = innerModifier, enterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Left, @@ -311,6 +315,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { onOpenChannel = onOpenChannel, ) } + } } } } diff --git a/app/src/main/java/dev/typetype/android/AppNavigation.kt b/app/src/main/java/dev/typetype/android/AppNavigation.kt index 934de63e..e6a8b9e2 100644 --- a/app/src/main/java/dev/typetype/android/AppNavigation.kt +++ b/app/src/main/java/dev/typetype/android/AppNavigation.kt @@ -1,5 +1,9 @@ package dev.typetype.android +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars + import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -146,11 +150,15 @@ internal fun AppBottomBar( onTabClick: (TopLevelTab) -> Unit, tabs: List = topLevelTabs, modifier: Modifier = Modifier, + expanded: Boolean = false, ) { androidx.compose.foundation.layout.Column(modifier = modifier) { HorizontalDivider(color = MaterialTheme.colorScheme.outline) NavigationBar( - modifier = Modifier.testTag(APP_BOTTOM_NAVIGATION_TAG), + modifier = Modifier.testTag(APP_BOTTOM_NAVIGATION_TAG) + .then(if (expanded) Modifier.height( + 96.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding(), + ) else Modifier), containerColor = MaterialTheme.colorScheme.surface, ) { tabs.forEach { tab: TopLevelTab -> @@ -158,8 +166,10 @@ internal fun AppBottomBar( NavigationBarItem( selected = selected, onClick = { onTabClick(tab) }, - icon = { Icon(painterResource(tab.iconRes), contentDescription = null) }, - label = { Text(stringResource(tab.labelRes)) }, + icon = { Icon(painterResource(tab.iconRes), contentDescription = null, + modifier = Modifier.size(if (expanded) 32.dp else 24.dp)) }, + label = { Text(stringResource(tab.labelRes), style = if (expanded) + MaterialTheme.typography.titleSmall else MaterialTheme.typography.labelMedium) }, ) } } diff --git a/app/src/main/java/dev/typetype/android/AppShell.kt b/app/src/main/java/dev/typetype/android/AppShell.kt index 29e1ce4d..40731a8f 100644 --- a/app/src/main/java/dev/typetype/android/AppShell.kt +++ b/app/src/main/java/dev/typetype/android/AppShell.kt @@ -1,7 +1,14 @@ package dev.typetype.android +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity + import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.background +import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -92,6 +99,8 @@ fun AppShell( ) var selectedTabRoute by rememberSaveable { mutableStateOf(null) } var isPlayerFullscreen by remember { mutableStateOf(false) } + var bottomNavigationHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current var playerTransitionProgress by remember { mutableFloatStateOf(0f) } val playerHostState by playerHostController.state.collectAsStateWithLifecycle() val appChromeVisible = isAppChromeVisible(playerHostState.target, isPlayerFullscreen) @@ -117,20 +126,10 @@ fun AppShell( LocalMediaController provides mediaController, LocalAppSnackbarHost provides snackbarHostState, ) { - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val usesNavigationRail = + BoxWithConstraints(modifier = modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + val tabletLayout = minOf(maxWidth, maxHeight) >= WIDE_NAVIGATION_THRESHOLD Row(modifier = Modifier.fillMaxSize()) { - if (usesNavigationRail && showsNavigation && appChromeVisible) { - AppNavigationRail( - selectedTabRouteQualifiedName = selectedTabRouteQualifiedName, - onTabClick = { tab: TopLevelTab -> - selectedTabRoute = tab.route::class.qualifiedName - navController.navigateTopLevel(tab.route, selectedTabRoute) - }, - tabs = navigationTabs, - ) - } Box(modifier = Modifier.weight(1f)) { Scaffold( contentWindowInsets = if (isPlayerFullscreen || isShorts) { @@ -139,7 +138,7 @@ fun AppShell( WindowInsets.systemBars }, topBar = { - if (isTopLevel && !isShorts && !usesNavigationRail) { + if (isTopLevel && !isShorts && !tabletLayout) { AppTopBar( onOpenSearch = onOpenSearch, onOpenNotifications = onOpenNotifications, @@ -165,15 +164,20 @@ fun AppShell( } }, bottomBar = { - if (!usesNavigationRail && showsNavigation) { + if (showsNavigation && !isPlayerFullscreen) { AppBottomBar( + expanded = tabletLayout, selectedTabRouteQualifiedName = selectedTabRouteQualifiedName, onTabClick = { tab: TopLevelTab -> + if (tabletLayout && playerHostState.target == PlayerHostTarget.Expanded) { + playerHostController.minimize() + } + navController.navigateTopLevel(tab.route, selectedTabRouteQualifiedName) selectedTabRoute = tab.route::class.qualifiedName - navController.navigateTopLevel(tab.route, selectedTabRoute) }, tabs = navigationTabs, - modifier = Modifier.playerChrome(phoneChromeAlpha), + modifier = (if (tabletLayout) Modifier else Modifier.playerChrome(phoneChromeAlpha)) + .onSizeChanged { bottomNavigationHeight = with(density) { it.height.toDp() } }, ) } }, @@ -183,6 +187,7 @@ fun AppShell( Modifier .fillMaxSize() .padding(padding) + .consumeWindowInsets(padding) .padding( bottom = if (playerHostState.target == PlayerHostTarget.Mini) { 64.dp @@ -194,8 +199,14 @@ fun AppShell( } PlayerHost( controller = playerHostController, + reserveNavigationBarInset = !tabletLayout || !showsNavigation || isPlayerFullscreen, + modifier = Modifier.padding( + bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + ).consumeWindowInsets(PaddingValues( + bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + )), bottomBarHeightDp = if ( - !usesNavigationRail && showsNavigation + !tabletLayout && showsNavigation && !isPlayerFullscreen ) { NAV_BAR_HEIGHT_DP } else { @@ -242,7 +253,7 @@ internal fun playerPhoneChromeAlpha( else -> 0f } -private fun NavHostController.navigateTopLevel( +internal fun NavHostController.navigateTopLevel( route: Any, activeTabRouteQualifiedName: String?, ) { diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt index 0f60404f..82093334 100644 --- a/app/src/main/java/dev/typetype/android/MainActivity.kt +++ b/app/src/main/java/dev/typetype/android/MainActivity.kt @@ -15,6 +15,10 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.runtime.getValue +import androidx.compose.runtime.SideEffect +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.luminance +import androidx.core.view.WindowCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -88,6 +92,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { setContent { val preferences by viewModel.preferences.collectAsStateWithLifecycle() TypeTypeTheme(preferences = preferences) { + val lightBackground = MaterialTheme.colorScheme.background.luminance() > 0.5f + SideEffect { + WindowCompat.getInsetsController(window, window.decorView) + .isAppearanceLightStatusBars = lightBackground + } val state by viewModel.state.collectAsStateWithLifecycle() val startRoute = state.startRoute val pendingCrashReport = state.pendingCrashReport diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index ffe525fa..231d9292 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -19,6 +19,7 @@ import dev.typetype.android.core.ui.navigation.SettingsRoute import dev.typetype.android.core.ui.navigation.StorageSettingsRoute import dev.typetype.android.feature.settings.SettingsRoute as SettingsRouteScreen import dev.typetype.android.feature.settings.about.LicensesScreen +import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceScreen import dev.typetype.android.feature.settings.imports.PortabilityRoute as ImportDataRouteScreen import dev.typetype.android.feature.settings.rss.RssFeedsRoute as RssFeedsRouteScreen @@ -27,6 +28,9 @@ internal fun NavGraphBuilder.settingsDestinations( onSignOut: () -> Unit, ) { composable { + if (LocalSettingsTwoPane.current) { + AppearanceScreen(onNavigateBack = { navController.popBackStack() }) + } else { SettingsRouteScreen( onNavigateBack = { navController.popBackStack() }, onOpenAccounts = { navController.navigate(AccountsRoute) }, @@ -43,6 +47,7 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenAbout = { navController.navigate(AboutRoute) }, onSignOut = onSignOut, ) + } } composable { LicensesScreen( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 884c7ecf..35362233 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -139,7 +139,7 @@ fun RelatedVideoCard( Text( text = stringResource(R.string.video_views_short, formatRelatedViews(video.viewCount)), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) } diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt index 1fd95839..03e15f23 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeAuthBackdrop.kt @@ -19,12 +19,11 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.compose.material3.MaterialTheme import dev.typetype.android.R @Composable fun TypeTypeAuthBackdrop(content: @Composable () -> Unit) { - val background = MaterialTheme.colorScheme.background + val background = androidx.compose.material3.MaterialTheme.colorScheme.background val dark = background.luminance() < 0.5f Box(Modifier.fillMaxSize().background(background)) { Box( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index ac670f48..b506092b 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -158,7 +158,7 @@ fun VideoCard( Text( text = it, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outlineVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } diff --git a/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt b/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt index 07d6e076..834afdd4 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/util/WindowHelper.kt @@ -20,7 +20,7 @@ object WindowHelper { } } - WindowCompat.setDecorFitsSystemWindows(window, !isFullscreen) + WindowCompat.setDecorFitsSystemWindows(window, false) val noLimits = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS if (isFullscreen) { diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt index 78b4e007..ea8acbb5 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.channel import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -51,6 +52,27 @@ internal fun ChannelHeader( subscribeInFlight: Boolean, onToggleSubscribe: () -> Unit, onNavigateBack: () -> Unit, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + ChannelHeaderContent( + channel = channel, + isSubscribed = isSubscribed, + subscribeInFlight = subscribeInFlight, + onToggleSubscribe = onToggleSubscribe, + onNavigateBack = onNavigateBack, + expanded = maxWidth >= 600.dp, + ) + } +} + +@Composable +private fun ChannelHeaderContent( + channel: Channel, + isSubscribed: Boolean, + subscribeInFlight: Boolean, + onToggleSubscribe: () -> Unit, + onNavigateBack: () -> Unit, + expanded: Boolean, ) { val serverBaseUrl = LocalServerBaseUrl.current Column(modifier = Modifier.fillMaxWidth()) { @@ -60,7 +82,10 @@ internal fun ChannelHeader( Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(BannerAspectRatio) + .then( + if (expanded) Modifier.height(220.dp) + else Modifier.aspectRatio(BannerAspectRatio), + ) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( @@ -82,12 +107,12 @@ internal fun ChannelHeader( contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .size(AvatarSize) + .size(if (expanded) 88.dp else AvatarSize) .clip(CircleShape) .background(MaterialTheme.colorScheme.surfaceVariant), ) Column(modifier = Modifier.weight(1f)) { - ChannelName(channel) + ChannelName(channel, expanded) Spacer(Modifier.height(2.dp)) Text( text = formatSubscribers(channel.subscriberCount), @@ -99,6 +124,7 @@ internal fun ChannelHeader( isSubscribed = isSubscribed, enabled = !subscribeInFlight, onClick = onToggleSubscribe, + expanded = expanded, ) } } @@ -120,11 +146,11 @@ private fun ChannelBackButton(onNavigateBack: () -> Unit) { } @Composable -private fun ChannelName(channel: Channel) { +private fun ChannelName(channel: Channel, expanded: Boolean) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = channel.name, - style = MaterialTheme.typography.titleMedium.copy( + style = (if (expanded) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.titleMedium).copy( fontWeight = FontWeight.SemiBold, letterSpacing = (-0.2).sp, ), @@ -146,7 +172,7 @@ private fun ChannelName(channel: Channel) { } @Composable -private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () -> Unit) { +private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () -> Unit, expanded: Boolean) { Button( onClick = onClick, enabled = enabled, @@ -160,7 +186,7 @@ private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () contentColor = MaterialTheme.colorScheme.onSurface, ), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp), - modifier = Modifier.height(34.dp), + modifier = Modifier.height(if (expanded) 48.dp else 34.dp), ) { Text( text = stringResource( 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 79c48faa..06f521e4 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 @@ -280,6 +280,12 @@ fun LoadedPlayer( } } }, + recommendations = { recommendationsModifier -> + PlayerRecommendations( + stream, userSettings, onPlayVideo, onOpenChannel, onAction, + recommendationsModifier, + ) + }, details = { detailsModifier -> PlayerDetails( stream = stream, @@ -296,7 +302,6 @@ fun LoadedPlayer( onAction = onAction, onShowComments = { commentsVisible = true }, onShowDownloads = { downloadPickerVisible = true }, - onPlayVideo = onPlayVideo, onOpenChannel = onOpenChannel, onToggleSubscription = onToggleSubscription, modifier = detailsModifier, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt index 27ca382d..77042a7a 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerContentLayout.kt @@ -59,6 +59,7 @@ internal fun PlayerContentLayout( modifier: Modifier = Modifier, viewport: @Composable (Modifier) -> Unit, details: @Composable (Modifier) -> Unit, + recommendations: @Composable (Modifier) -> Unit = {}, ) { val currentViewport by rememberUpdatedState(viewport) val retainedViewport = remember { @@ -113,6 +114,11 @@ internal fun PlayerContentLayout( .playerDetailsTransition(hostTransitionProgress) .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) + recommendations( + Modifier.fillMaxWidth() + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), + ) } } PlayerContentLayoutMode.TwoPane -> { @@ -121,11 +127,11 @@ internal fun PlayerContentLayout( .fillMaxSize() .testTag(PLAYER_TWO_PANE_LAYOUT_TAG), ) { - Box( + Column( modifier = Modifier .weight(PLAYER_PANE_WEIGHT) - .fillMaxHeight(), - contentAlignment = Alignment.TopStart, + .fillMaxHeight() + .verticalScroll(rememberScrollState()), ) { retainedViewport( Modifier @@ -140,8 +146,13 @@ internal fun PlayerContentLayout( .aspectRatio(VIDEO_ASPECT_RATIO) .testTag(PLAYER_VIEWPORT_TAG), ) + details( + Modifier.fillMaxWidth() + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), + ) } - details( + recommendations( Modifier .weight(DETAILS_PANE_WEIGHT) .fillMaxHeight() diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt index 184796b6..a039357b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt @@ -2,7 +2,6 @@ package dev.typetype.android.feature.player import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -38,12 +37,10 @@ internal fun PlayerDetails( onAction: (PlayerAction) -> Unit, onShowComments: () -> Unit, onShowDownloads: () -> Unit, - onPlayVideo: (String) -> Unit, onOpenChannel: (String) -> Unit, onToggleSubscription: () -> Unit, modifier: Modifier = Modifier, ) { - val videoMenuScope = rememberVideoMenuScope(onOpenChannel = onOpenChannel) val branding = rememberVideoBranding( sourceUrl = videoUrl, title = stream.title, @@ -92,16 +89,26 @@ internal fun PlayerDetails( onSubscribeClick = onToggleSubscription, ) HorizontalDivider(color = MaterialTheme.colorScheme.outline) - if (!userSettings.hideRelatedVideos) { - RelatedStreamsSection( - videos = stream.relatedStreams, - onPlayVideo = onPlayVideo, - menuScope = videoMenuScope, - onOpenChannel = onOpenChannel, - autoplayEnabled = userSettings.autoplay, - onAutoplayChange = { onAction(PlayerAction.OnSetAutoplay(it)) }, - modifier = Modifier.fillMaxWidth(), - ) - } } } + +@Composable +internal fun PlayerRecommendations( + stream: Stream, + userSettings: UserSettings, + onPlayVideo: (String) -> Unit, + onOpenChannel: (String) -> Unit, + onAction: (PlayerAction) -> Unit, + modifier: Modifier = Modifier, +) { + if (userSettings.hideRelatedVideos) return + RelatedStreamsSection( + videos = stream.relatedStreams, + onPlayVideo = onPlayVideo, + menuScope = rememberVideoMenuScope(onOpenChannel = onOpenChannel), + onOpenChannel = onOpenChannel, + autoplayEnabled = userSettings.autoplay, + onAutoplayChange = { onAction(PlayerAction.OnSetAutoplay(it)) }, + modifier = modifier.padding(16.dp), + ) +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt index 5b9ee373..2bc4362f 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerInteractionRow.kt @@ -3,6 +3,13 @@ package dev.typetype.android.feature.player import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState @@ -21,6 +28,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -35,6 +45,7 @@ import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.ShareChooserSheet @Composable +@OptIn(ExperimentalLayoutApi::class) fun PlayerInteractionRow( isFavorited: Boolean, isInWatchLater: Boolean, @@ -52,11 +63,9 @@ fun PlayerInteractionRow( ) { val serverBaseUrl = LocalServerBaseUrl.current var shareSheetOpen by remember { mutableStateOf(false) } - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { + val actions: @Composable (Boolean) -> Unit = { expanded -> PlayerActionButton( + expanded = expanded, icon = if (isFavorited) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, contentDescription = stringResource( if (isFavorited) R.string.player_remove_from_favorites @@ -66,6 +75,7 @@ fun PlayerInteractionRow( onClick = onToggleFavorite, ) PlayerActionButton( + expanded = expanded, icon = if (isInWatchLater) Icons.Filled.WatchLater else Icons.Outlined.WatchLater, contentDescription = stringResource( if (isInWatchLater) R.string.player_remove_from_watch_later @@ -75,12 +85,14 @@ fun PlayerInteractionRow( onClick = onToggleWatchLater, ) PlayerActionButton( + expanded = expanded, icon = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.player_add_to_playlist), onClick = onAddToPlaylist, ) onShowComments?.let { PlayerActionButton( + expanded = expanded, icon = Icons.Outlined.ChatBubbleOutline, contentDescription = stringResource(R.string.comments_title), onClick = it, @@ -88,6 +100,7 @@ fun PlayerInteractionRow( } if (audioOnlyAvailable) { PlayerActionButton( + expanded = expanded, icon = Icons.Filled.GraphicEq, contentDescription = stringResource(R.string.player_audio_only), selected = audioOnlyEnabled, @@ -96,17 +109,32 @@ fun PlayerInteractionRow( ) } PlayerActionButton( + expanded = expanded, icon = Icons.Filled.Download, contentDescription = stringResource(R.string.player_download), enabled = !downloadInFlight, onClick = onDownload, ) PlayerActionButton( + expanded = expanded, icon = Icons.Filled.Share, contentDescription = stringResource(R.string.video_menu_share), onClick = { shareSheetOpen = true }, ) } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth >= 600.dp) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { actions(true) } + } else { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { actions(false) } + } + } if (shareSheetOpen) { ShareChooserSheet( serverBaseUrl = serverBaseUrl, @@ -121,27 +149,46 @@ private fun PlayerActionButton( icon: ImageVector, contentDescription: String, onClick: () -> Unit, + expanded: Boolean, selected: Boolean = false, enabled: Boolean = true, ) { - Surface( - shape = CircleShape, - color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + Column( + modifier = if (expanded) Modifier.width(100.dp) else Modifier, + horizontalAlignment = Alignment.CenterHorizontally, ) { - IconButton(onClick = onClick, enabled = enabled) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = when { - selected -> MaterialTheme.colorScheme.primary - enabled -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, + Surface( + shape = CircleShape, + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(if (expanded) 72.dp else 48.dp), + ) { + Icon( + imageVector = icon, + modifier = Modifier.size(if (expanded) 36.dp else 24.dp), + contentDescription = contentDescription, + tint = when { + selected -> MaterialTheme.colorScheme.primary + enabled -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + if (expanded) { + Text( + text = contentDescription, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 6.dp), ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt index 0885843d..d516d683 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerCenterControls.kt @@ -28,12 +28,14 @@ internal fun PlayerCenterControls( player: Player, isFullscreen: Boolean, compact: Boolean = false, + expanded: Boolean = false, modifier: Modifier = Modifier, ) { val playPauseState = rememberPlayPauseButtonState(player) val seekBackState = rememberSeekBackButtonState(player) val seekForwardState = rememberSeekForwardButtonState(player) val spacing = when { + expanded -> 48.dp isFullscreen -> 44.dp compact -> 18.dp else -> 26.dp @@ -50,11 +52,13 @@ internal fun PlayerCenterControls( enabled = seekBackState.isEnabled, onClick = { seekBackState.onClick() }, buttonSize = when { + expanded -> 72.dp isFullscreen -> 62.dp compact -> 40.dp else -> 50.dp }, iconSize = when { + expanded -> 40.dp isFullscreen -> 32.dp compact -> 22.dp else -> 26.dp @@ -68,11 +72,13 @@ internal fun PlayerCenterControls( enabled = playPauseState.isEnabled, onClick = { playPauseState.onClick() }, buttonSize = when { + expanded -> 96.dp isFullscreen -> 74.dp compact -> 48.dp else -> 62.dp }, iconSize = when { + expanded -> 60.dp isFullscreen -> 48.dp compact -> 30.dp else -> 38.dp @@ -85,11 +91,13 @@ internal fun PlayerCenterControls( enabled = seekForwardState.isEnabled, onClick = { seekForwardState.onClick() }, buttonSize = when { + expanded -> 72.dp isFullscreen -> 62.dp compact -> 40.dp else -> 50.dp }, iconSize = when { + expanded -> 40.dp isFullscreen -> 32.dp compact -> 22.dp else -> 26.dp 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 72b8fb9d..acb53053 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 @@ -51,6 +51,7 @@ fun PlayerControls( ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT + val expandedControls = maxWidth >= 600.dp && maxHeight >= 300.dp if (!timelineScrubbing) { TopScrim( compact = compactControls, @@ -75,6 +76,7 @@ fun PlayerControls( isPipAvailable = isPipAvailable, chaptersAvailable = chaptersAvailable, compact = compactControls, + expanded = expandedControls, modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() @@ -89,6 +91,7 @@ fun PlayerControls( ) PlayerCenterControls( player = player, + expanded = expandedControls, isFullscreen = isFullscreen, compact = compactControls, modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), @@ -102,6 +105,7 @@ fun PlayerControls( onTimelineScrubbingChange = onTimelineScrubbingChange, isFullscreen = isFullscreen, compact = compactControls, + expanded = expandedControls, onToggleFullscreen = onToggleFullscreen, modifier = Modifier .align(Alignment.BottomCenter) @@ -163,6 +167,7 @@ private fun BottomBar( onTimelineScrubbingChange: (Boolean) -> Unit, isFullscreen: Boolean, compact: Boolean, + expanded: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { @@ -171,6 +176,7 @@ private fun BottomBar( modifier = modifier .height( when { + expanded -> 72.dp isFullscreen -> 52.dp compact -> 36.dp else -> 40.dp @@ -183,7 +189,8 @@ private fun BottomBar( player = player, segments = sponsorBlockSegments, previewPositionMs = seekPreviewPositionMs, - compact = !isFullscreen, + compact = !isFullscreen && !expanded, + expanded = expanded, onScrubbingChange = onTimelineScrubbingChange, modifier = Modifier.weight(1f), ) @@ -192,6 +199,7 @@ private fun BottomBar( onClick = onToggleFullscreen, modifier = Modifier.size( when { + expanded -> 64.dp isFullscreen -> 48.dp compact -> 36.dp else -> 40.dp @@ -204,6 +212,7 @@ private fun BottomBar( ), contentDescription = stringResource(R.string.player_fullscreen), tint = Color.White, + modifier = Modifier.size(if (expanded) 36.dp else 24.dp), ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt new file mode 100644 index 00000000..14d43cea --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt @@ -0,0 +1,29 @@ +package dev.typetype.android.feature.player.components + +import kotlin.math.abs +import kotlin.math.sign + +internal fun proportionalSeekTarget(startMs: Long, dragX: Float, width: Float, durationMs: Long): Long { + if (durationMs <= 0 || width <= 0 || !dragX.isFinite()) return startMs + val fraction = (dragX / width).coerceIn(-1f, 1f) + val rangeMs = minOf(durationMs, 180_000L) + val accelerated = fraction * (0.35f + 0.65f * abs(fraction)) + return (startMs + (accelerated * rangeMs).toLong()).coerceIn(0L, durationMs) +} + +internal class HoldSpeedSteps(private val stepPx: Float) { + var factor: Float = 2f + private set + private var anchorY = 0f + + fun update(dragY: Float): Float { + val distance = dragY - anchorY + val threshold = stepPx.coerceAtLeast(1f) + if (abs(distance) >= threshold) { + val steps = (abs(distance) / threshold).toInt() + factor = (factor - sign(distance) * steps * 0.25f).coerceIn(0.25f, 4f) + anchorY += sign(distance) * steps * threshold + } + return factor + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt new file mode 100644 index 00000000..436cfe06 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.feature.player.components + +data class PlayerGestureConfig( + val doubleTapSeekEnabled: Boolean = true, + val doubleTapSeekSeconds: Int = 10, + val swipeSeekEnabled: Boolean = false, + val swipeBrightnessVolumeEnabled: Boolean = true, + val longPressSpeedEnabled: Boolean = true, + val accessibleControlsEnabled: Boolean = false, +) 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 9b5fbd7b..66bba828 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 @@ -6,15 +6,8 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.automirrored.filled.VolumeDown -import androidx.compose.material.icons.automirrored.filled.VolumeMute -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.filled.BrightnessHigh -import androidx.compose.material.icons.filled.BrightnessLow -import androidx.compose.material.icons.filled.BrightnessMedium import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -22,10 +15,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R import dev.typetype.android.feature.player.state.DragMode @@ -34,20 +27,10 @@ import dev.typetype.android.feature.player.state.PlayerGestureState import dev.typetype.android.feature.player.state.ResizeMode import kotlin.math.abs -private const val SEEK_DRAG_MS_PER_PIXEL = 80f private const val DIRECTION_LOCK_THRESHOLD_PX = 18f private const val LEVEL_DRAG_VIEW_FRACTION = 0.75f private const val LONG_PRESS_SPEED_FACTOR = 2f -data class PlayerGestureConfig( - val doubleTapSeekEnabled: Boolean = true, - val doubleTapSeekSeconds: Int = 10, - val swipeSeekEnabled: Boolean = false, - val swipeBrightnessVolumeEnabled: Boolean = true, - val longPressSpeedEnabled: Boolean = true, - val accessibleControlsEnabled: Boolean = false, -) - @Composable fun PlayerGestureLayer( player: Player, @@ -66,6 +49,14 @@ fun PlayerGestureLayer( onVolumeGestureStart: () -> Float = { state.volumeFraction.floatValue }, ) { var savedSpeed by remember { mutableFloatStateOf(1f) } + var holdSpeed by remember { mutableFloatStateOf(2f) } + fun restoreSpeed() { + if (state.longPressBoostActive.value) { + player.setPlaybackSpeed(savedSpeed) + state.longPressBoostActive.value = false + } + } + DisposableEffect(player) { onDispose { restoreSpeed() } } Box( modifier = modifier .fillMaxSize() @@ -79,83 +70,103 @@ fun PlayerGestureLayer( var lastPosition = down.position var totalDrag = Offset.Zero var mode = DragMode.None + val speedSteps = HoldSpeedSteps(24.dp.toPx()) + var speedDragY = 0f state.dragMode.value = DragMode.None state.seekDragStartMs.longValue = player.currentPosition state.seekDragTargetMs.longValue = player.currentPosition - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.changes.count { it.pressed } > 1) { - resetDragState(state) - break - } - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) { - when (mode) { - DragMode.Seek -> player.seekTo(state.seekDragTargetMs.longValue) - DragMode.FullscreenEnter -> onEnterFullscreenGesture() - DragMode.FullscreenExit -> onExitFullscreenGesture() - else -> Unit + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.changes.count { it.pressed } > 1) { + resetDragState(state) + break } - resetDragState(state) - break - } - val current = change.position - val delta = current - lastPosition - lastPosition = current - totalDrag += delta - if (mode == DragMode.None) { - if (abs(totalDrag.x) < DIRECTION_LOCK_THRESHOLD_PX && - abs(totalDrag.y) < DIRECTION_LOCK_THRESHOLD_PX - ) continue - val candidate = pickDragMode( - dragAmount = totalDrag, - startX = startX, - width = size.width.toFloat(), - ) - val allowed = when (candidate) { - DragMode.Seek -> config.swipeSeekEnabled - DragMode.Brightness, - DragMode.Volume, - -> isFullscreen && config.swipeBrightnessVolumeEnabled - DragMode.FullscreenEnter -> !isFullscreen - DragMode.FullscreenExit -> isFullscreen && fullscreenExitGestureEnabled - DragMode.None -> false + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + when (mode) { + DragMode.Seek -> player.seekTo(state.seekDragTargetMs.longValue) + DragMode.FullscreenEnter -> onEnterFullscreenGesture() + DragMode.FullscreenExit -> onExitFullscreenGesture() + else -> Unit + } + resetDragState(state) + break } - if (!allowed) continue - mode = candidate - state.dragMode.value = candidate - onGestureFeedback() - when (candidate) { - DragMode.Brightness -> { - state.brightnessFraction.floatValue = onBrightnessGestureStart() - state.brightnessOverlayActive.value = true + val current = change.position + val delta = current - lastPosition + lastPosition = current + totalDrag += delta + if (state.longPressBoostActive.value) { + change.consume() + speedDragY += delta.y + val nextSpeed = speedSteps.update(speedDragY) + if (nextSpeed != holdSpeed) { + holdSpeed = nextSpeed + player.setPlaybackSpeed(nextSpeed) + onGestureFeedback() + } + continue + } + if (mode == DragMode.None) { + if (abs(totalDrag.x) < DIRECTION_LOCK_THRESHOLD_PX && + abs(totalDrag.y) < DIRECTION_LOCK_THRESHOLD_PX + ) continue + val candidate = pickDragMode( + dragAmount = totalDrag, + startX = startX, + width = size.width.toFloat(), + ) + val allowed = when (candidate) { + DragMode.Seek -> config.swipeSeekEnabled && player.duration > 0 + DragMode.Brightness, + DragMode.Volume, + -> isFullscreen && config.swipeBrightnessVolumeEnabled + DragMode.FullscreenEnter -> !isFullscreen + DragMode.FullscreenExit -> isFullscreen && fullscreenExitGestureEnabled + DragMode.None -> false } - DragMode.Volume -> { - state.volumeFraction.floatValue = onVolumeGestureStart() - state.volumeOverlayActive.value = true + if (!allowed) continue + mode = candidate + state.dragMode.value = candidate + onGestureFeedback() + when (candidate) { + DragMode.Brightness -> { + state.brightnessFraction.floatValue = onBrightnessGestureStart() + state.brightnessOverlayActive.value = true + } + DragMode.Volume -> { + state.volumeFraction.floatValue = onVolumeGestureStart() + state.volumeOverlayActive.value = true + } + DragMode.Seek -> state.seekDragOverlayActive.value = true + DragMode.FullscreenEnter -> Unit + DragMode.FullscreenExit -> Unit + DragMode.None -> Unit } - DragMode.Seek -> state.seekDragOverlayActive.value = true - DragMode.FullscreenEnter -> Unit - DragMode.FullscreenExit -> Unit - DragMode.None -> Unit + } + if (mode != DragMode.None) { + change.consume() + handleDragMode( + player = player, + state = state, + mode = mode, + delta = delta, + totalDragX = totalDrag.x, + viewportWidth = size.width.toFloat(), + levelDragRangePx = levelDragRangePx( + size.width.toFloat(), + size.height.toFloat(), + ), + onAdjustBrightness = onAdjustBrightness, + onAdjustVolume = onAdjustVolume, + ) } } - if (mode != DragMode.None) { - change.consume() - handleDragMode( - player = player, - state = state, - mode = mode, - delta = delta, - levelDragRangePx = levelDragRangePx( - size.width.toFloat(), - size.height.toFloat(), - ), - onAdjustBrightness = onAdjustBrightness, - onAdjustVolume = onAdjustVolume, - ) - } + } finally { + restoreSpeed() + resetDragState(state) } } } @@ -189,18 +200,10 @@ fun PlayerGestureLayer( onLongPress = { if (!config.longPressSpeedEnabled) return@detectTapGestures savedSpeed = player.playbackParameters.speed + holdSpeed = LONG_PRESS_SPEED_FACTOR player.setPlaybackSpeed(LONG_PRESS_SPEED_FACTOR) state.longPressBoostActive.value = true - }, - onPress = { - try { - awaitRelease() - } finally { - if (state.longPressBoostActive.value) { - player.setPlaybackSpeed(savedSpeed) - state.longPressBoostActive.value = false - } - } + onGestureFeedback() }, ) } @@ -234,7 +237,7 @@ fun PlayerGestureLayer( icon = volumeLevelIcon(state.volumeFraction.floatValue), modifier = Modifier.align(Alignment.Center), ) - SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = LONG_PRESS_SPEED_FACTOR) + SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = holdSpeed) } } @@ -246,6 +249,8 @@ private fun handleDragMode( state: PlayerGestureState, mode: DragMode, delta: Offset, + totalDragX: Float, + viewportWidth: Float, levelDragRangePx: Float, onAdjustBrightness: (Float) -> Unit, onAdjustVolume: (Float) -> Unit, @@ -270,10 +275,9 @@ private fun handleDragMode( onAdjustVolume(next) } DragMode.Seek -> { - val deltaMs = (delta.x * SEEK_DRAG_MS_PER_PIXEL).toLong() - val duration = if (player.duration > 0) player.duration else Long.MAX_VALUE - state.seekDragTargetMs.longValue = - (state.seekDragTargetMs.longValue + deltaMs).coerceIn(0L, duration) + state.seekDragTargetMs.longValue = proportionalSeekTarget( + state.seekDragStartMs.longValue, totalDragX, viewportWidth, player.duration, + ) } DragMode.FullscreenEnter, DragMode.FullscreenExit, @@ -288,19 +292,6 @@ internal fun adjustLevelFraction(current: Float, deltaY: Float, dragRangePx: Flo internal fun levelDragRangePx(width: Float, height: Float): Float = minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION -internal fun brightnessLevelIcon(fraction: Float): ImageVector = when { - fraction < 0.25f -> Icons.Filled.BrightnessLow - fraction < 0.75f -> Icons.Filled.BrightnessMedium - else -> Icons.Filled.BrightnessHigh -} - -internal fun volumeLevelIcon(fraction: Float): ImageVector = when { - fraction <= 0f -> Icons.AutoMirrored.Filled.VolumeOff - fraction < 0.25f -> Icons.AutoMirrored.Filled.VolumeMute - fraction < 0.75f -> Icons.AutoMirrored.Filled.VolumeDown - else -> Icons.AutoMirrored.Filled.VolumeUp -} - internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.Seek startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt new file mode 100644 index 00000000..0f0ce166 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt @@ -0,0 +1,24 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeMute +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.filled.BrightnessHigh +import androidx.compose.material.icons.filled.BrightnessLow +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.ui.graphics.vector.ImageVector + +internal fun brightnessLevelIcon(fraction: Float): ImageVector = when { + fraction < 0.25f -> Icons.Filled.BrightnessLow + fraction < 0.75f -> Icons.Filled.BrightnessMedium + else -> Icons.Filled.BrightnessHigh +} + +internal fun volumeLevelIcon(fraction: Float): ImageVector = when { + fraction <= 0f -> Icons.AutoMirrored.Filled.VolumeOff + fraction < 0.25f -> Icons.AutoMirrored.Filled.VolumeMute + fraction < 0.75f -> Icons.AutoMirrored.Filled.VolumeDown + else -> Icons.AutoMirrored.Filled.VolumeUp +} 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 2ad577b6..7b4387c3 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 @@ -4,13 +4,8 @@ import android.media.AudioManager import android.graphics.Rect import androidx.activity.compose.LocalActivity import androidx.annotation.OptIn -import androidx.compose.animation.AnimatedVisibility -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.padding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -215,132 +210,66 @@ internal fun PlayerSurfaceBox( modifier = chromeModifier.align(Alignment.Center), ) - if (!isInPip && playbackStatus.acceptsInput && !accessibleControls && - gesturesVisible - ) { - PlayerGestureLayer( - player = player, - state = gestureState, - onSingleTap = { - controlsVisible = !controlsVisible - }, - onAdjustBrightness = { fraction -> - val percent = (fraction * 100).toInt() - if (percent != appliedBrightnessPercent) activity?.window?.let { window -> - appliedBrightnessPercent = percent - onPlaybackBrightnessChange(percent) - window.applyPlaybackBrightness(percent) - } - }, - onAdjustVolume = { fraction -> - audioManager?.let { manager -> - val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - val target = (fraction * maxVolume).toInt().coerceIn(0, maxVolume) - if (target != appliedVolumeLevel) { - appliedVolumeLevel = target - manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) - } - } - }, - onBrightnessGestureStart = { - val fraction = activity?.window?.attributes?.screenBrightness - ?.takeIf { it in 0f..1f } - ?: appliedBrightnessPercent - .takeIf { it in 0..100 } - ?.div(100f) - ?: gestureState.brightnessFraction.floatValue - gestureState.brightnessFraction.floatValue = fraction - fraction - }, - onVolumeGestureStart = { - val fraction = audioManager?.let { manager -> - val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) - if (maxVolume > 0) { - manager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat() - } else { - 0f - } - } ?: gestureState.volumeFraction.floatValue - gestureState.volumeFraction.floatValue = fraction - fraction - }, - onGestureFeedback = { - controlsVisible = false - hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) - }, - isFullscreen = isFullscreen, - onEnterFullscreenGesture = { - if (!isFullscreen) onToggleFullscreen() - }, - onExitFullscreenGesture = { - if (isFullscreen) onToggleFullscreen() - }, - fullscreenExitGestureEnabled = false, - config = gestureConfig, - modifier = Modifier.fillMaxSize(), - ) - } - - 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.padding(start = 4.dp, end = 4.dp) - }, - ), - ) - } + PlayerSurfaceGestureLayer( + player = player, + state = gestureState, + activity = activity, + audioManager = audioManager, + hapticFeedback = hapticFeedback, + playbackStatus = playbackStatus, + isInPip = isInPip, + isFullscreen = isFullscreen, + accessibleControls = accessibleControls, + gesturesVisible = gesturesVisible, + controlsVisible = controlsVisible, + config = gestureConfig, + appliedBrightnessPercent = appliedBrightnessPercent, + onAppliedBrightnessChange = { appliedBrightnessPercent = it }, + onPlaybackBrightnessChange = onPlaybackBrightnessChange, + appliedVolumeLevel = appliedVolumeLevel, + onAppliedVolumeChange = { appliedVolumeLevel = it }, + onControlsVisibleChange = { controlsVisible = it }, + onToggleFullscreen = onToggleFullscreen, + modifier = Modifier.fillMaxSize(), + ) - AnimatedVisibility( - visible = controlsAllowedByProgress && - (controlsVisible || accessibleControls) && - !isInPip && playbackStatus.acceptsInput, - enter = fadeIn(), - exit = fadeOut(), - ) { - PlayerControls( - player = player, - title = stream.title, - onNavigateBack = onNavigateBack, - onOpenOptions = { optionsVisible = true }, - onOpenChapters = { chaptersVisible = true }, - onEnterPip = { - controlsVisible = false - enterPictureInPicture( - activity, - isPlaying = playbackStatus.isPlaying, - audioOnlyAvailable = audioOnlyState.available, - sourceRect = pipSourceRect, - ) - }, - onToggleFullscreen = onToggleFullscreen, - onCycleResizeMode = { - gestureState.resizeMode.value = gestureState.resizeMode.value.next() - }, - resizeMode = gestureState.resizeMode.value, - isFullscreen = isFullscreen, - isPipAvailable = isPipAvailable, - chaptersAvailable = chapters.isNotEmpty(), - sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, - seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue - .takeIf { gestureState.seekDragOverlayActive.value }, - onTimelineScrubbingChange = { timelineScrubbing = it }, - modifier = chromeModifier.fillMaxSize(), - ) - } + PlayerSurfaceChrome( + player = player, + title = stream.title, + sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, + seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue + .takeIf { gestureState.seekDragOverlayActive.value }, + seekDragOverlayVisible = gestureState.seekDragOverlayActive.value, + seekDragPositionMs = gestureState.seekDragTargetMs.longValue, + isFullscreen = isFullscreen, + isInPip = isInPip, + controlsAllowedByProgress = controlsAllowedByProgress, + controlsVisible = controlsVisible, + accessibleControls = accessibleControls, + acceptsInput = playbackStatus.acceptsInput, + timelineScrubbing = timelineScrubbing, + onTimelineScrubbingChange = { timelineScrubbing = it }, + onNavigateBack = onNavigateBack, + onOpenOptions = { optionsVisible = true }, + onOpenChapters = { chaptersVisible = true }, + onEnterPip = { + controlsVisible = false + enterPictureInPicture( + activity, + isPlaying = playbackStatus.isPlaying, + audioOnlyAvailable = audioOnlyState.available, + sourceRect = pipSourceRect, + ) + }, + onToggleFullscreen = onToggleFullscreen, + onCycleResizeMode = { + gestureState.resizeMode.value = gestureState.resizeMode.value.next() + }, + resizeMode = gestureState.resizeMode.value, + isPipAvailable = isPipAvailable, + chaptersAvailable = chapters.isNotEmpty(), + modifier = chromeModifier, + ) PlayerSurfaceFeedback( player = player, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt new file mode 100644 index 00000000..bb7a8267 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt @@ -0,0 +1,103 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.material3.Text +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.session.MediaController +import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.feature.player.state.ResizeMode + +@Composable +internal fun PlayerSurfaceChrome( + player: MediaController, + title: String, + sponsorBlockSegments: List, + seekPreviewPositionMs: Long?, + seekDragOverlayVisible: Boolean, + seekDragPositionMs: Long, + isFullscreen: Boolean, + isInPip: Boolean, + controlsAllowedByProgress: Boolean, + controlsVisible: Boolean, + accessibleControls: Boolean, + acceptsInput: Boolean, + timelineScrubbing: Boolean, + onTimelineScrubbingChange: (Boolean) -> Unit, + onNavigateBack: () -> Unit, + onOpenOptions: () -> Unit, + onOpenChapters: () -> Unit, + onEnterPip: () -> Unit, + onToggleFullscreen: () -> Unit, + onCycleResizeMode: () -> Unit, + resizeMode: ResizeMode, + isPipAvailable: Boolean, + chaptersAvailable: Boolean, + modifier: Modifier = Modifier, +) { + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = seekDragOverlayVisible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = formatPlayerTime(seekDragPositionMs), color = Color.White) + PlayerSeekScrubOverlay( + player = player, + positionMs = seekDragPositionMs, + segments = sponsorBlockSegments, + isFullscreen = isFullscreen, + modifier = Modifier + .fillMaxWidth() + .then( + if (isFullscreen) { + Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier.padding(start = 4.dp, end = 4.dp) + }, + ), + ) + } + } + + AnimatedVisibility( + visible = controlsAllowedByProgress && + (controlsVisible || accessibleControls) && + !isInPip && acceptsInput && !seekDragOverlayVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + PlayerControls( + player = player, + title = title, + onNavigateBack = onNavigateBack, + onOpenOptions = onOpenOptions, + onOpenChapters = onOpenChapters, + onEnterPip = onEnterPip, + onToggleFullscreen = onToggleFullscreen, + onCycleResizeMode = onCycleResizeMode, + resizeMode = resizeMode, + isFullscreen = isFullscreen, + isPipAvailable = isPipAvailable, + chaptersAvailable = chaptersAvailable, + sponsorBlockSegments = sponsorBlockSegments, + seekPreviewPositionMs = seekPreviewPositionMs, + onTimelineScrubbingChange = onTimelineScrubbingChange, + timelineScrubbing = timelineScrubbing, + modifier = modifier.fillMaxSize(), + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt new file mode 100644 index 00000000..c308e2e0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceGestures.kt @@ -0,0 +1,96 @@ +package dev.typetype.android.feature.player.components + +import android.app.Activity +import android.media.AudioManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.media3.session.MediaController +import dev.typetype.android.feature.player.state.PlayerGestureState + +@Composable +internal fun PlayerSurfaceGestureLayer( + player: MediaController, + state: PlayerGestureState, + activity: Activity?, + audioManager: AudioManager?, + hapticFeedback: HapticFeedback, + playbackStatus: PlayerPlaybackStatus, + isInPip: Boolean, + isFullscreen: Boolean, + accessibleControls: Boolean, + gesturesVisible: Boolean, + controlsVisible: Boolean, + config: PlayerGestureConfig, + appliedBrightnessPercent: Int, + onAppliedBrightnessChange: (Int) -> Unit, + onPlaybackBrightnessChange: (Int) -> Unit, + appliedVolumeLevel: Int, + onAppliedVolumeChange: (Int) -> Unit, + onControlsVisibleChange: (Boolean) -> Unit, + onToggleFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!isInPip && playbackStatus.acceptsInput && !accessibleControls && gesturesVisible) { + PlayerGestureLayer( + player = player, + state = state, + onSingleTap = { onControlsVisibleChange(!controlsVisible) }, + onAdjustBrightness = { fraction -> + val percent = (fraction * 100).toInt() + if (percent != appliedBrightnessPercent) activity?.window?.let { window -> + onAppliedBrightnessChange(percent) + onPlaybackBrightnessChange(percent) + window.applyPlaybackBrightness(percent) + } + }, + onAdjustVolume = { fraction -> + audioManager?.let { manager -> + val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val target = (fraction * maxVolume).toInt().coerceIn(0, maxVolume) + if (target != appliedVolumeLevel) { + onAppliedVolumeChange(target) + manager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) + } + } + }, + onBrightnessGestureStart = { + val fraction = activity?.window?.attributes?.screenBrightness + ?.takeIf { it in 0f..1f } + ?: appliedBrightnessPercent + .takeIf { it in 0..100 } + ?.div(100f) + ?: state.brightnessFraction.floatValue + state.brightnessFraction.floatValue = fraction + fraction + }, + onVolumeGestureStart = { + val fraction = audioManager?.let { manager -> + val maxVolume = manager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (maxVolume > 0) { + manager.getStreamVolume(AudioManager.STREAM_MUSIC) / maxVolume.toFloat() + } else { + 0f + } + } ?: state.volumeFraction.floatValue + state.volumeFraction.floatValue = fraction + fraction + }, + onGestureFeedback = { + onControlsVisibleChange(false) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, + isFullscreen = isFullscreen, + onEnterFullscreenGesture = { + if (!isFullscreen) onToggleFullscreen() + }, + onExitFullscreenGesture = { + if (isFullscreen) onToggleFullscreen() + }, + fullscreenExitGestureEnabled = false, + config = config, + modifier = modifier, + ) + } +} 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 028e0bda..931ec824 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme @@ -65,6 +66,7 @@ fun PlayerTimeBar( modifier: Modifier = Modifier, segments: List = emptyList(), compact: Boolean = false, + expanded: Boolean = false, previewPositionMs: Long? = null, onScrubbingChange: (Boolean) -> Unit = {}, ) { @@ -86,12 +88,12 @@ fun PlayerTimeBar( ) { Text( text = positionLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), color = Color.White, modifier = if (compact) { Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) } else { - Modifier.width(TIME_LABEL_WIDTH) + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) }, textAlign = TextAlign.End, ) @@ -100,6 +102,7 @@ fun PlayerTimeBar( durationMs = durationMs, segments = segments, compact = compact, + expanded = expanded, emphasized = emphasized, onScrub = { onScrubbingChange(true) @@ -123,16 +126,16 @@ fun PlayerTimeBar( modifier = Modifier .weight(1f) .padding(horizontal = if (compact) 2.dp else 4.dp) - .height(if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), + .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), ) Text( text = durationLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), color = Color.White.copy(alpha = 0.7f), modifier = if (compact) { Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) } else { - Modifier.width(TIME_LABEL_WIDTH) + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) }, ) } @@ -145,6 +148,7 @@ internal fun TimelineTrack( segments: List, compact: Boolean, emphasized: Boolean = false, + expanded: Boolean = false, interactive: Boolean = true, onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, @@ -154,7 +158,7 @@ internal fun TimelineTrack( modifier: Modifier = Modifier, ) { val activeColor = MaterialTheme.colorScheme.primary - val inactiveColor = Color.Black.copy(alpha = 0.38f) + val inactiveColor = if (expanded) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.38f) val timelineModifier = if (interactive) { modifier .semantics { @@ -201,11 +205,13 @@ internal fun TimelineTrack( ) { Canvas(modifier = Modifier.fillMaxSize()) { val targetTrackHeight = when { + expanded -> if (emphasized) 10.dp else 6.dp emphasized -> SCRUBBING_TRACK_HEIGHT compact -> COMPACT_TRACK_HEIGHT else -> TRACK_HEIGHT } val targetThumbSize = when { + expanded -> if (emphasized) 32.dp else 24.dp emphasized -> SCRUBBING_THUMB_SIZE compact -> COMPACT_THUMB_WIDTH else -> THUMB_WIDTH @@ -275,13 +281,15 @@ internal fun PlayerSeekScrubOverlay( isFullscreen: Boolean, modifier: Modifier = Modifier, ) { - Box(modifier = modifier.height(if (isFullscreen) 52.dp else 40.dp)) { + BoxWithConstraints(modifier = modifier) { + val expanded = maxWidth >= 600.dp TimelineTrack( positionMs = positionMs, durationMs = player.duration.coerceAtLeast(0L), segments = segments, compact = false, emphasized = true, + expanded = expanded, interactive = false, onScrub = {}, onScrubFinished = {}, @@ -290,7 +298,8 @@ internal fun PlayerSeekScrubOverlay( accessibilityStateDescription = "", modifier = Modifier .align(Alignment.Center) - .fillMaxSize() + .fillMaxWidth() + .height(if (expanded) 72.dp else if (isFullscreen) 52.dp else 40.dp) .padding(horizontal = 4.dp), ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt index fd3d0b6b..209f5b1b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTopControls.kt @@ -40,17 +40,18 @@ internal fun PlayerTopBar( isPipAvailable: Boolean, chaptersAvailable: Boolean, compact: Boolean = false, + expanded: Boolean = false, modifier: Modifier = Modifier, ) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - BackButton(onNavigateBack, compact) + BackButton(onNavigateBack, compact, expanded) if (isFullscreen) { Text( text = title, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleMedium, + style = if (expanded) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), ) } else { @@ -66,20 +67,23 @@ internal fun PlayerTopBar( isPipAvailable, chaptersAvailable, compact, + expanded, ) } } @Composable -private fun BackButton(onNavigateBack: () -> Unit, compact: Boolean) { +private fun BackButton(onNavigateBack: () -> Unit, compact: Boolean, expanded: Boolean) { OverlayIconButton( onClick = onNavigateBack, compact = compact, + expanded = expanded, modifier = Modifier.padding(if (compact) 4.dp else 8.dp), ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.player_back), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } @@ -96,42 +100,47 @@ private fun TopActions( isPipAvailable: Boolean, chaptersAvailable: Boolean, compact: Boolean, + expanded: Boolean, ) { Row( modifier = Modifier.padding(if (compact) 4.dp else 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isFullscreen) { - OverlayIconButton(onCycleResizeMode, compact = compact) { + OverlayIconButton(onCycleResizeMode, compact = compact, expanded = expanded) { Icon( imageVector = resizeMode.icon(), contentDescription = stringResource(R.string.player_resize_mode), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } if (isPipAvailable) { - OverlayIconButton(onEnterPip, compact = compact) { + OverlayIconButton(onEnterPip, compact = compact, expanded = expanded) { Icon( painter = painterResource(R.drawable.ic_pip), contentDescription = stringResource(R.string.player_pip), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } if (chaptersAvailable) { - OverlayIconButton(onOpenChapters, compact = compact) { + OverlayIconButton(onOpenChapters, compact = compact, expanded = expanded) { Icon( imageVector = Icons.AutoMirrored.Filled.List, contentDescription = stringResource(R.string.player_chapters), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } } - OverlayIconButton(onOpenOptions, compact = compact) { + OverlayIconButton(onOpenOptions, compact = compact, expanded = expanded) { Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.player_playback_options), + modifier = Modifier.size(if (expanded) 32.dp else 24.dp), tint = Color.White, ) } @@ -143,11 +152,18 @@ private fun OverlayIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, compact: Boolean = false, + expanded: Boolean = false, content: @Composable () -> Unit, ) { IconButton( onClick = onClick, - modifier = modifier.padding(2.dp).size(if (compact) 36.dp else 40.dp), + modifier = modifier.padding(2.dp).size( + when { + expanded -> 64.dp + compact -> 36.dp + else -> 40.dp + }, + ), content = content, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt index d2189e4b..9281112e 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHost.kt @@ -40,6 +40,8 @@ fun PlayerHost( onOpenChannel: (channelUrl: String) -> Unit, onOpenAccounts: () -> Unit, onClosePlayback: () -> Unit, + modifier: Modifier = Modifier, + reserveNavigationBarInset: Boolean = true, accessibleControlsEnabled: Boolean = false, onTransitionProgressChange: (Float) -> Unit = {}, content: @Composable () -> Unit, @@ -60,9 +62,12 @@ fun PlayerHost( stateSaver = FullscreenOrientationState.Saver, ) { mutableStateOf(FullscreenOrientationState()) } - val navigationBarsBottom = WindowInsets.navigationBars.asPaddingValues() - .calculateBottomPadding() - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val navigationBarsBottom = if (reserveNavigationBarInset) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + } else { + 0.dp + } + BoxWithConstraints(modifier = modifier.fillMaxSize()) { val containerHeightPx = constraints.maxHeight.toFloat().coerceAtLeast(1f) val miniHeightPx = with(density) { MINI_PLAYER_HEIGHT.toPx() } val bottomBarPx = with(density) { bottomBarHeightDp.dp.toPx() } diff --git a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt index 223c52dd..ea0e4d7d 100644 --- a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastHeader.kt @@ -38,21 +38,23 @@ fun PodcastHeader( hasMore: Boolean, onPlay: () -> Unit, onShuffle: () -> Unit, + stacked: Boolean = false, ) { val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + val thumbnail: @Composable (Modifier) -> Unit = { imageModifier -> AsyncImage( model = buildImageUrl(serverBaseUrl, podcast.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.width(132.dp).aspectRatio(1f).clip(RoundedCornerShape(14.dp)), + modifier = imageModifier.aspectRatio(1f).clip(RoundedCornerShape(14.dp)), ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) { + } + val identity: @Composable (Modifier) -> Unit = { identityModifier -> + Column(modifier = identityModifier, verticalArrangement = Arrangement.spacedBy(5.dp)) { Text( text = podcast.title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), @@ -78,6 +80,16 @@ fun PodcastHeader( ) } } + if (stacked) { + thumbnail(Modifier.fillMaxWidth()) + identity(Modifier.fillMaxWidth()) + } else { + Row(Modifier.fillMaxWidth()) { + thumbnail(Modifier.width(132.dp)) + Spacer(Modifier.width(14.dp)) + identity(Modifier.weight(1f)) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt index 9a4d790a..5474a02b 100644 --- a/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/podcast/PodcastScreen.kt @@ -2,6 +2,13 @@ package dev.typetype.android.feature.podcast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -128,50 +135,66 @@ internal fun PodcastContentGrid( ) { val podcast = requireNotNull(state.podcast) val episodes = state.episodes.filterNot(menuScope::isHidden) - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 300.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-header") { - PodcastHeader( - podcast = podcast, - loadedCount = episodes.size, - hasMore = state.nextPage != null, - onPlay = { onPlayQueue(podcast.title, episodes, false) }, - onShuffle = { onPlayQueue(podcast.title, episodes, true) }, - ) - } - items(episodes, key = { it.url }, contentType = { "podcast-episode" }) { episode -> - VideoCard( - video = episode, - onClick = { onPlayVideo(episode.url) }, - onChannelClick = episode.uploaderUrl.takeIf(String::isNotBlank)?.let { url -> - { onOpenChannel(url) } - }, - onMenuAction = { action -> menuScope.onAction(action, episode) }, - menuItemState = menuScope.stateFor(episode), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - if (episodes.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(R.string.podcast_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(24.dp), - ) + val gridState = rememberLazyGridState() + val header: @Composable (Boolean) -> Unit = { stacked -> + PodcastHeader( + stacked = stacked, + podcast = podcast, + loadedCount = episodes.size, + hasMore = state.nextPage != null, + onPlay = { onPlayQueue(podcast.title, episodes, false) }, + onShuffle = { onPlayQueue(podcast.title, episodes, true) }, + ) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Column(Modifier.width(320.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + header(true) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 300.dp), + state = gridState, + modifier = Modifier.weight(1f).fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (!twoPane) { + item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-header") { header(false) } + } + items(episodes, key = { it.url }, contentType = { "podcast-episode" }) { episode -> + VideoCard( + video = episode, + onClick = { onPlayVideo(episode.url) }, + onChannelClick = episode.uploaderUrl.takeIf(String::isNotBlank)?.let { url -> + { onOpenChannel(url) } + }, + onMenuAction = { action -> menuScope.onAction(action, episode) }, + menuItemState = menuScope.stateFor(episode), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + if (episodes.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.podcast_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(24.dp), + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-pagination") { + PodcastPagination( + cursor = state.nextPage, + loading = state.isLoadingMore, + failed = state.loadMoreError, + onLoadMore = onLoadMore, + ) + } } - } - item(span = { GridItemSpan(maxLineSpan) }, key = "podcast-pagination") { - PodcastPagination( - cursor = state.nextPage, - loading = state.isLoadingMore, - failed = state.loadMoreError, - onLoadMore = onLoadMore, - ) } } } diff --git a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt index bf70ac71..1f8c86a8 100644 --- a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistHeader.kt @@ -46,21 +46,23 @@ fun PublicPlaylistHeader( onPlay: () -> Unit, onShuffle: () -> Unit, onToggleSaved: () -> Unit, + stacked: Boolean = false, ) { val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row(modifier = Modifier.fillMaxWidth()) { + val thumbnail: @Composable (Modifier) -> Unit = { imageModifier -> AsyncImage( model = buildImageUrl(serverBaseUrl, playlist.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.width(150.dp).aspectRatio(16f / 9f).clip(RoundedCornerShape(12.dp)), + modifier = imageModifier.aspectRatio(16f / 9f).clip(RoundedCornerShape(12.dp)), ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + } + val identity: @Composable (Modifier) -> Unit = { identityModifier -> + Column(modifier = identityModifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( text = playlist.title, style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), @@ -85,6 +87,16 @@ fun PublicPlaylistHeader( ) } } + if (stacked) { + thumbnail(Modifier.fillMaxWidth()) + identity(Modifier.fillMaxWidth()) + } else { + Row(Modifier.fillMaxWidth()) { + thumbnail(Modifier.width(150.dp)) + Spacer(Modifier.width(14.dp)) + identity(Modifier.weight(1f)) + } + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt index 2953ea8c..1f2abbe6 100644 --- a/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/publicplaylist/PublicPlaylistScreen.kt @@ -2,6 +2,13 @@ package dev.typetype.android.feature.publicplaylist import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -132,53 +139,69 @@ internal fun PublicPlaylistContentGrid( ) { val playlist = requireNotNull(state.playlist) val videos = state.videos.filterNot(menuScope::isHidden) - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 300.dp), - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-header") { - PublicPlaylistHeader( - playlist = playlist, - loadedCount = videos.size, - hasMore = state.nextPage != null, - canSave = state.canSave, - isSaved = state.savedItemId != null, - saveInFlight = state.saveInFlight, - saveErrorMessage = state.saveErrorMessage, - onPlay = { onPlayQueue(playlist.title, videos, false) }, - onShuffle = { onPlayQueue(playlist.title, videos, true) }, - onToggleSaved = onToggleSaved, - ) - } - items(videos, key = { it.url }, contentType = { "public-playlist-video" }) { video -> - VideoCard( - video = video, - onClick = { onPlayVideo(video.url) }, - onChannelClick = { onOpenChannel(video.uploaderUrl) }, - onMenuAction = { action -> menuScope.onAction(action, video) }, - menuItemState = menuScope.stateFor(video), - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - if (videos.isEmpty()) { - item(span = { GridItemSpan(maxLineSpan) }) { - Text( - text = stringResource(R.string.public_playlist_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth().padding(24.dp), - ) + val gridState = rememberLazyGridState() + val header: @Composable (Boolean) -> Unit = { stacked -> + PublicPlaylistHeader( + stacked = stacked, + playlist = playlist, + loadedCount = videos.size, + hasMore = state.nextPage != null, + canSave = state.canSave, + isSaved = state.savedItemId != null, + saveInFlight = state.saveInFlight, + saveErrorMessage = state.saveErrorMessage, + onPlay = { onPlayQueue(playlist.title, videos, false) }, + onShuffle = { onPlayQueue(playlist.title, videos, true) }, + onToggleSaved = onToggleSaved, + ) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val twoPane = maxWidth >= 840.dp && maxHeight >= 480.dp + Row(Modifier.fillMaxSize()) { + if (twoPane) { + Column(Modifier.width(320.dp).fillMaxHeight().verticalScroll(rememberScrollState())) { + header(true) + } + } + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 300.dp), + state = gridState, + modifier = Modifier.weight(1f).fillMaxHeight(), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (!twoPane) { + item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-header") { header(false) } + } + items(videos, key = { it.url }, contentType = { "public-playlist-video" }) { video -> + VideoCard( + video = video, + onClick = { onPlayVideo(video.url) }, + onChannelClick = { onOpenChannel(video.uploaderUrl) }, + onMenuAction = { action -> menuScope.onAction(action, video) }, + menuItemState = menuScope.stateFor(video), + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + if (videos.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text( + text = stringResource(R.string.public_playlist_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(24.dp), + ) + } + } + item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-pagination") { + PublicPlaylistPagination( + cursor = state.nextPage, + loading = state.isLoadingMore, + failed = state.loadMoreError, + onLoadMore = onLoadMore, + ) + } } - } - item(span = { GridItemSpan(maxLineSpan) }, key = "playlist-pagination") { - PublicPlaylistPagination( - cursor = state.nextPage, - loading = state.isLoadingMore, - failed = state.loadMoreError, - onLoadMore = onLoadMore, - ) } } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt b/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt index 06ed4b00..a7ddcd5a 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/SettingsRoute.kt @@ -21,10 +21,12 @@ fun SettingsRoute( onOpenBlocked: () -> Unit, onOpenAbout: () -> Unit, onSignOut: () -> Unit, + selectedTitleRes: Int? = null, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() SettingsScreen( + selectedTitleRes = selectedTitleRes, onNavigateBack = onNavigateBack, onOpenAccounts = onOpenAccounts, onOpenProfile = onOpenProfile, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt index 45312b4d..3d7e1d76 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/SettingsScreen.kt @@ -1,6 +1,9 @@ package dev.typetype.android.feature.settings import androidx.compose.foundation.clickable +import androidx.compose.foundation.background +import androidx.compose.foundation.selection.selectable +import androidx.compose.ui.semantics.Role import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -63,6 +66,7 @@ fun SettingsScreen( onOpenBlocked: () -> Unit = {}, onOpenAbout: () -> Unit = {}, onSignOut: () -> Unit, + selectedTitleRes: Int? = null, ) { val entries = buildList { add(SettingsEntry(R.string.accounts_title, R.string.accounts_subtitle, Icons.Filled.ManageAccounts, onOpenAccounts)) @@ -102,7 +106,7 @@ fun SettingsScreen( modifier = Modifier.weight(1f), ) { items(entries, contentType = { "settings-entry" }) { entry -> - SettingsCategoryRow(entry = entry) + SettingsCategoryRow(entry = entry, selected = selectedTitleRes?.let { it == entry.titleRes }) HorizontalDivider( modifier = Modifier.padding(start = 64.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.48f), @@ -145,11 +149,18 @@ private fun SettingsTopBar(onNavigateBack: () -> Unit) { } @Composable -private fun SettingsCategoryRow(entry: SettingsEntry) { +private fun SettingsCategoryRow(entry: SettingsEntry, selected: Boolean?) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = entry.onClick) + .background( + if (selected == true) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.background, + ) + .then( + if (selected == null) Modifier.clickable(onClick = entry.onClick) + else Modifier.selectable(selected = selected, role = Role.Tab, onClick = entry.onClick), + ) .padding(horizontal = 20.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt b/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt index abcafde2..daebaa1b 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/login/LoginScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding @@ -99,9 +100,10 @@ fun LoginScreen( } Column( modifier = Modifier - .fillMaxSize() + .fillMaxHeight() .padding(horizontal = 24.dp) .widthIn(max = 480.dp) + .fillMaxWidth() .align(Alignment.Center) .imePadding() .padding(top = 72.dp, bottom = 24.dp) diff --git a/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt b/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt index beba92a9..c81dd381 100644 --- a/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/setup/welcome/WelcomeScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -60,7 +63,8 @@ fun WelcomeScreen(onAction: (WelcomeAction) -> Unit) { .padding(horizontal = 24.dp, vertical = 48.dp), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxHeight().widthIn(max = 480.dp) + .fillMaxWidth().align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.weight(1f)) diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt new file mode 100644 index 00000000..658672d5 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt @@ -0,0 +1,43 @@ +package dev.typetype.android.feature.player.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerDragResponseTest { + @Test fun `seek scales with viewport rather than pixel density`() { + assertEquals( + proportionalSeekTarget(100_000, 100f, 1000f, 600_000), + proportionalSeekTarget(100_000, 200f, 2000f, 600_000), + ) + } + + @Test fun `larger movement accelerates and reversing returns to anchor`() { + val small = proportionalSeekTarget(100_000, 100f, 1000f, 600_000) - 100_000 + val large = proportionalSeekTarget(100_000, 200f, 1000f, 600_000) - 100_000 + assertTrue(large > small * 2) + assertEquals(100_000L, proportionalSeekTarget(100_000, 0f, 1000f, 600_000)) + assertEquals(0L, proportionalSeekTarget(0, -500f, 1000f, 600_000)) + assertEquals(600_000L, proportionalSeekTarget(600_000, 500f, 1000f, 600_000)) + } + + @Test fun `invalid duration leaves playback unchanged`() { + assertEquals(15_000L, proportionalSeekTarget(15_000, 100f, 1000f, -1)) + } + + @Test fun `hold starts at two and ignores jitter`() { + val steps = HoldSpeedSteps(24f) + assertEquals(2f, steps.update(-23f)) + assertEquals(2.25f, steps.update(-24f)) + assertEquals(2.25f, steps.update(-22f)) + assertEquals(2f, steps.update(0f)) + } + + @Test fun `hold limits speed and responds immediately when reversing at bounds`() { + val steps = HoldSpeedSteps(24f) + assertEquals(4f, steps.update(-480f)) + assertEquals(3.75f, steps.update(-456f)) + assertEquals(0.25f, steps.update(480f)) + assertEquals(0.5f, steps.update(456f)) + } +} diff --git a/release-notes/v1.7.0.md b/release-notes/v1.7.0.md new file mode 100644 index 00000000..72b90945 --- /dev/null +++ b/release-notes/v1.7.0.md @@ -0,0 +1,64 @@ +# TypeType for Android 1.7.0 + +TypeType for Android 1.7.0 brings a smoother player, a more responsive tablet experience, and more consistent navigation across the application. + +The application is still in beta. + +## Playback + +- Keep the seekbar visible while seeking after the player controls have faded out. +- Improve seekbar positioning in portrait, landscape, and full screen, with a clearer scrubber during an active seek. +- Keep the player surface stable when returning from the background and preserve the current playback layout through player transitions. +- Improve brightness and volume gesture feedback, including haptic feedback and the PipePipe-inspired interaction model. +- Keep player actions, fullscreen controls, and the mini-player aligned across window sizes. + +## Tablet experience + +- Add responsive two-pane layouts for channels, podcasts, playlists, and settings. +- Use larger navigation, player actions, labels, and touch targets on wide screens. +- Keep portrait and landscape spacing independent so the player and bottom navigation do not overlap. +- Preserve the mobile layout while making tablet browsing and playback easier to use. + +## Navigation and sharing + +- Synchronize the selected top-level tab immediately when navigation changes. +- Return to a tab's root when its active navigation item is selected again. +- Restore channel and player state more consistently when moving between recommendations and uploaders. +- Add provider-aware source links and a redesigned mobile share target chooser. +- Improve the login layout and keyboard insets, including the TypeType visual backdrop. + +## Appearance and accessibility + +- Preserve Dynamic Color accents when the OLED surface option is enabled. +- Improve contrast and readability across the adaptive layouts and shared cards. +- Expose larger tablet actions and labels without removing compact controls on phones. + +## Compatibility and validation + +- Keep the Android 6.0 through Android 17 support range, API 23 through API 37. +- Keep core behavior independent from Google Play Services and preserve the server-first playback boundary. +- The release candidate passed the Android unit-test, lint, debug-build, and release-build checks locally. + +Android TV remains a separate workstream and is not part of this mobile release. + +## Thx + +Thx to @therealresonix for the detailed player, fullscreen, seeking, navigation, theme, and tablet reports, plus the follow-up testing that helped verify each correction. + +Thx to @gursuj for the playback-control and codec feedback, and to @ollietheotter983 for testing across instances and sharing real-world playback problems. + +Thx to @Alifoss, @303inmyheart, @Toni-Vide, @pinembour, @LuckeeSoft, @VitoItalianGamer, @Slashic, and @arcoast for the earlier reports and suggestions that continue to shape TypeType Android. + +And thank you to everyone testing the beta, sharing reproductions, reporting regressions, and helping TypeType Android improve. + +## Installing + +Download the APK attached below. It is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +The stable and beta F-Droid channels use the same application identity. Switching channels updates the existing installation instead of installing a second TypeType application. + +If something does not work correctly, please open an issue in the [TypeType-Android repository](https://github.com/TypeType-Video/TypeType-Android/issues). + +If you want to support TypeType Android, sharing it with others is the best way to help. GitHub Sponsors also helps cover the server, domain, and coffee costs that keep the project moving. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.6.1...v1.7.0