diff --git a/README.md b/README.md index 283f1dff..0c880621 100644 --- a/README.md +++ b/README.md @@ -21,73 +21,62 @@ -TypeType Android is a native client for +TypeType Android is the native client for [TypeType](https://github.com/TypeType-Video/TypeType), a self-hosted video -platform. It provides browsing, account synchronization, downloads, and native -playback on Android phones and tablets. +platform. The app is in beta, supports Android 6.0 and newer, and does not +require Google Play Services. -The Android app is currently in beta and receives frequent updates. - -The app communicates exclusively with the TypeType instance selected during -setup. Extraction, playback sessions, recommendations, synchronization, and -server-side downloads remain server responsibilities. +TypeType Android uses the TypeType Server selected during setup for extraction, +playback sessions, recommendations, synchronization, and downloads. ## Screenshots -| Home and Continue Watching | Search | Subscriptions | -| --- | --- | --- | -| ![TypeType home feed and Continue Watching on Android](assets/screenshots/android-home.png) | ![TypeType video search on Android](assets/screenshots/android-search.png) | ![TypeType subscriptions feed on Android](assets/screenshots/android-subscriptions.png) | - -| Library and history | Native player | Comments | +| Welcome | Add an instance | Home | | --- | --- | --- | -| ![TypeType library and history on Android](assets/screenshots/android-library.png) | ![TypeType native video player on Android](assets/screenshots/android-player.png) | ![TypeType video comments on Android](assets/screenshots/android-comments.png) | +| ![TypeType Android welcome screen](assets/screenshots/android-welcome.png) | ![TypeType Android add instance screen](assets/screenshots/android-add-instance.png) | ![TypeType Android home feed](assets/screenshots/android-home.png) | -| Notifications | Profile | Settings | +| Search | Native player | Settings | | --- | --- | --- | -| ![TypeType notifications on Android](assets/screenshots/android-notifications.png) | ![TypeType profile and animated avatar support on Android](assets/screenshots/android-profile.png) | ![TypeType settings on Android](assets/screenshots/android-settings.png) | +| ![TypeType Android search screen](assets/screenshots/android-search.png) | ![TypeType Android native video player](assets/screenshots/android-player.png) | ![TypeType Android settings screen](assets/screenshots/android-settings.png) | ## Features -- Resume unfinished videos from Continue Watching. -- Search videos, channels, playlists, music, and other supported content. -- Browse subscriptions with labels for live, premiere, and special videos. -- Access history, favorites, Watch Later, playlists, and notifications. -- Use background audio, Picture in Picture, the mini-player, and audio-only - playback. +- Browse and search videos, channels, playlists, music, and subscriptions. +- Resume videos, manage history, favorites, Watch Later, playlists, and + notifications. +- Play in the background, in Picture in Picture, or with the mini-player. - Choose quality, codec, audio track, captions, playback speed, and image mode. -- Use chapters, SponsorBlock, the playback queue, comments, related videos, and - the sleep timer. -- Download supported videos and retain cached pages during temporary network - interruptions. -- Import existing data and manage multiple TypeType instances and accounts. - -TypeType Android supports Android 6.0 and newer, and does not require Google -Play Services. +- Use chapters, SponsorBlock, comments, related videos, the queue, and the + sleep timer. +- Download supported videos and import existing data. ## Install ### F-Droid -Use the official TypeType F-Droid repository to install the app and receive -stable updates: +TypeType Android is one application with two F-Droid channels: + +- **Stable** is the recommended channel for normal use. +- **Beta** is for testing prerelease builds. + +Both channels use the same application package and signing certificate, so they +update the same installation. Add only Stable for normal use, or add both +repositories to receive Beta updates. Choose a channel from the +[TypeType F-Droid setup page](https://typetype.video/fdroid/): 1. Open the [TypeType F-Droid setup page](https://typetype.video/fdroid/) on your Android device, or scan its QR code. -2. Tap **Open in F-Droid**, add the repository, then wait for the catalog to - refresh. +2. Tap **Open in F-Droid**, add the repository, and refresh the catalog. 3. Search for **TypeType** and install it. ### Signed APK -1. Open the +1. Download the signed APK from the [latest Release](https://github.com/TypeType-Video/TypeType-Android/releases/latest). -2. Download the signed `TypeType-vX.Y.Z.apk`. A matching SHA-256 file is - available if you want to verify the download. -3. Open the APK and allow installation from your browser or file manager when - Android asks. -4. Launch TypeType, enter the address of your TypeType instance, then sign in - with a local account or OIDC. Guest access appears when the instance supports - it. +2. Verify it with the matching SHA-256 file if desired. +3. Open the APK and allow installation when Android asks. +4. Enter your TypeType instance address and sign in, use OIDC, or continue as a + guest when supported. Installing a newer signed Release over an existing Release keeps the application data. If Android reports an incompatible signature, remove any @@ -99,17 +88,6 @@ Debug build before installing the signed APK. > access to a compatible TypeType instance. If you want to host one, start with > the [self-hosting guide](https://typetype-video.github.io/Docs-TypeType/self-hosting/introduction). -## Offline behavior and diagnostics - -The app keeps available local cache entries visible while refreshing and -resumes progressive feeds after a temporary interruption. Playback, downloads, -and account operations still depend on the selected instance and its upstream -providers. - -When something fails, open **Settings > Diagnostics** to review a redacted, -local request history before sharing it. Diagnostics do not include raw -credentials, tokens, cookies, or private response bodies. - ## Help and feedback - Read the [TypeType user guide](https://typetype-video.github.io/Docs-TypeType/guide/). @@ -118,25 +96,17 @@ credentials, tokens, cookies, or private response bodies. - Report bugs and request features in the [TypeType Android issue tracker](https://github.com/TypeType-Video/TypeType-Android/issues). -When reporting a playback or network problem, include the Android version, app -version, TypeType instance version, video URL, the action that failed, and the -redacted diagnostics export when available. - -## TypeType ecosystem - -- [TypeType](https://github.com/TypeType-Video/TypeType), stack installation, - releases, and coordination -- [TypeType-Server](https://github.com/TypeType-Video/TypeType-Server), API, - extraction, playback sessions, and private user data -- [TypeType-Frontend](https://github.com/TypeType-Video/TypeType-Frontend), - browser client -- [Docs-TypeType](https://github.com/TypeType-Video/Docs-TypeType), user and - self-hosting documentation +When reporting a problem, include the Android version, app version, TypeType +Server version, the action that failed, and the redacted diagnostics export +when available. ## Acknowledgements -TypeType Android is an independent client. The following GPL v3 projects have -provided useful technical and product references: +We warmly thank the teams and contributors behind these projects. Their work, +ideas, and hard-earned lessons have been valuable references while building +TypeType Android, especially for Android compatibility, media playback, and +user experience. TypeType Android remains an independent client and is not +affiliated with these projects. - [PipePipe](https://github.com/InfinityLoop1308/PipePipe) and [PipePipeClient](https://github.com/InfinityLoop1308/PipePipeClient), for @@ -146,6 +116,8 @@ provided useful technical and product references: UX and Media3 playback lifecycle patterns - [Findroid](https://github.com/jarnedemeulemeester/findroid), for native server-first client architecture and multi-server setup patterns +- [Komi Store](https://github.com/kurikomi-labs/komi-store), for appearance + personalities and manga-inspired presentation patterns (Apache-2.0) ## License diff --git a/THIRD_PARTY_ASSETS.md b/THIRD_PARTY_ASSETS.md new file mode 100644 index 00000000..72c70930 --- /dev/null +++ b/THIRD_PARTY_ASSETS.md @@ -0,0 +1,19 @@ +# Third-party assets + +The data portability interface uses the following unmodified project icons for +identification. Each asset remains covered by its source project's license and +trademark rules; this repository's GPL v3 license does not relicense these +files. + +| Asset | Source file | Source revision | License | +|---|---|---|---| +| `flow.png` | [Flow icon](https://github.com/A-EDev/Flow/blob/c98a425bd55eca5a8d0edc74981d30c52379fb1b/fastlane/metadata/android/en-US/images/icon.png) | `c98a425bd55eca5a8d0edc74981d30c52379fb1b` | GPL-3.0 | +| `grayjay.png` | [Grayjay launcher icon](https://github.com/futo-org/grayjay-android/blob/3d62258fb2b28f65c3fe7d03357af7c63deb712f/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) | `3d62258fb2b28f65c3fe7d03357af7c63deb712f` | Source First License 1.1 | +| `materialious.png` | [Materialious launcher icon](https://github.com/Materialious/Materialious/blob/32121b880a7287f70de9ee71634fc11beba52ce4/materialious/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) | `32121b880a7287f70de9ee71634fc11beba52ce4` | AGPL-3.0 | +| `skytube.png` | [SkyTube launcher icon](https://github.com/SkyTubeTeam/SkyTube/blob/46d9d393f584a5698689dd4e0145058c6ca8801c/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) | `46d9d393f584a5698689dd4e0145058c6ca8801c` | GPL-3.0 | +| `viewtube.png` | [ViewTube icon](https://github.com/ViewTube/viewtube/blob/2b0183cba46e7a525e363c47bb6df7a62faf5dd1/client/app/assets/icon-192.png) | `2b0183cba46e7a525e363c47bb6df7a62faf5dd1` | AGPL-3.0 | +| `youtube-local.ico` | [youtube-local icon](https://github.com/user234683/youtube-local/blob/1adaf33f4572bbca423ab61d21b5654c8f3b56b9/youtube/static/favicon.ico) | `1adaf33f4572bbca423ab61d21b5654c8f3b56b9` | AGPL-3.0 | + +The remaining brand marks come from the `simple-icons` package or from the +TypeType and PipePipe project assets used by TypeType-Frontend at revision +`b5d15841d6544f7e9df343bdbd059caa5258ba3`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd6cadb3..e45d749c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.aboutlibraries) } val keystoreProperties = Properties() @@ -25,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10500 - versionName = "1.5.0" + versionCode = 10600 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } @@ -135,6 +136,7 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.aboutlibraries.compose) implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/app/src/androidTest/java/dev/typetype/android/ChannelNavigationTest.kt b/app/src/androidTest/java/dev/typetype/android/ChannelNavigationTest.kt new file mode 100644 index 00000000..248d7c7d --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/ChannelNavigationTest.kt @@ -0,0 +1,67 @@ +package dev.typetype.android + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.toRoute +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.core.ui.navigation.ChannelRoute +import dev.typetype.android.core.ui.navigation.HomeRoute +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChannelNavigationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun openingAnotherChannelCreatesStateForItsRoute() { + composeRule.setContent { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = HomeRoute) { + composable { + Button(onClick = { navController.navigateToChannel(FIRST_CHANNEL) }) { + Text(OPEN_FIRST) + } + } + composable { + val channelViewModel = viewModel() + Column { + Text(channelViewModel.channelUrl) + Button(onClick = { navController.navigateToChannel(SECOND_CHANNEL) }) { + Text(OPEN_SECOND) + } + } + } + } + } + + composeRule.onNodeWithText(OPEN_FIRST).performClick() + composeRule.onNodeWithText(FIRST_CHANNEL).assertExists() + composeRule.onNodeWithText(OPEN_SECOND).performClick() + composeRule.onNodeWithText(SECOND_CHANNEL).assertExists() + } + + private companion object { + const val OPEN_FIRST = "Open first channel" + const val OPEN_SECOND = "Open second channel" + const val FIRST_CHANNEL = "https://www.youtube.com/@first" + const val SECOND_CHANNEL = "https://www.youtube.com/@second" + } +} + +internal class ChannelRouteTestViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val channelUrl = savedStateHandle.toRoute().channelUrl +} diff --git a/app/src/androidTest/java/dev/typetype/android/TypeTypeImageLoaderTest.kt b/app/src/androidTest/java/dev/typetype/android/TypeTypeImageLoaderTest.kt new file mode 100644 index 00000000..a2a1b3ad --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/TypeTypeImageLoaderTest.kt @@ -0,0 +1,30 @@ +package dev.typetype.android + +import android.app.ActivityManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TypeTypeImageLoaderTest { + @Test + fun imageCacheUsesBoundedApplicationMemory() { + val context = ApplicationProvider.getApplicationContext() + val memoryClass = context.getSystemService(ActivityManager::class.java).memoryClass + val loader = createTypeTypeImageLoader(context) + + try { + assertEquals( + (memoryClass * 1024L * 1024L * EXPECTED_CACHE_PERCENT).toLong(), + loader.memoryCache?.maxSize, + ) + } finally { + loader.shutdown() + } + } +} + +private const val EXPECTED_CACHE_PERCENT = 0.10 diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/theme/AppearanceSchemeTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/AppearanceSchemeTest.kt new file mode 100644 index 00000000..ec6844ac --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/AppearanceSchemeTest.kt @@ -0,0 +1,109 @@ +package dev.typetype.android.core.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.MangaPaper +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppearanceSchemeTest { + @Test + fun mangaThemesChangeVisibleSurfaces() { + val accent = accentColors(AccentColor.Blue) + val default = mangaScheme( + paper = MangaPaper.Day, + theme = AppearanceTheme.TypeType, + accent = accent.first, + accentSoft = accent.second, + amoled = false, + isDark = false, + ) + val forest = mangaScheme( + paper = MangaPaper.Day, + theme = AppearanceTheme.Forest, + accent = accent.first, + accentSoft = accent.second, + amoled = false, + isDark = true, + ) + + assertNotEquals(default.background, forest.background) + assertNotEquals(default.surface, forest.surface) + } + + @Test + fun mangaAmoledUsesPureBlackSurfaces() { + val accent = accentColors(AccentColor.Blue) + val scheme = mangaScheme( + paper = MangaPaper.Nord, + theme = AppearanceTheme.Forest, + accent = accent.first, + accentSoft = accent.second, + amoled = true, + isDark = true, + ) + + assertEquals(Color.Black, scheme.background) + assertEquals(Color.Black, scheme.surface) + } + + @Test + fun mangaPaperKeepsTextContrastWhenColorModeDiffers() { + val accent = accentColors(AccentColor.Blue) + val dayPaperInDarkMode = mangaScheme( + paper = MangaPaper.Day, + theme = AppearanceTheme.TypeType, + accent = accent.first, + accentSoft = accent.second, + amoled = false, + isDark = true, + ) + val nightPaperInLightMode = mangaScheme( + paper = MangaPaper.Night, + theme = AppearanceTheme.TypeType, + accent = accent.first, + accentSoft = accent.second, + amoled = false, + isDark = false, + ) + + assertEquals(Color(0xFFF5F3EA), dayPaperInDarkMode.onBackground) + assertEquals(Color(0xFF211F1A), dayPaperInDarkMode.background) + assertEquals(Color(0xFFF5F3EA), nightPaperInLightMode.onBackground) + } + + @Test + fun everyMangaPaperKeepsItsInkContrastAcrossColorModes() { + val accent = accentColors(AccentColor.Blue) + val paperThemes = listOf(AppearanceTheme.TypeType, AppearanceTheme.Dynamic) + val modes = listOf(false, true) + + paperThemes.forEach { theme -> + MangaPaper.entries.forEach { paper -> + modes.forEach { isDark -> + val scheme = mangaScheme( + paper = paper, + theme = theme, + accent = accent.first, + accentSoft = accent.second, + amoled = false, + isDark = isDark, + ) + val expectedInk = if (paper == MangaPaper.Day && !isDark) { + Color(0xFF171717) + } else { + Color(0xFFF5F3EA) + } + + assertEquals(expectedInk, scheme.onBackground) + assertEquals(expectedInk, scheme.onSurface) + } + } + } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/DevicePlaybackCodecSupportAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/DevicePlaybackCodecSupportAndroidTest.kt index f8cf3544..fa3545bc 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/DevicePlaybackCodecSupportAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/DevicePlaybackCodecSupportAndroidTest.kt @@ -3,11 +3,14 @@ package dev.typetype.android.feature.player import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat +import androidx.media3.common.Format import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.StreamVideoSource +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -28,6 +31,18 @@ class DevicePlaybackCodecSupportAndroidTest { assertFalse(support.video(h264().copy(fps = 0)) == DecoderSupport.Unsupported) } + @Test + fun runtimeDecoderFailureRejectsTheMatchingAdvertisedFormat() { + val activeFormat = Format.Builder() + .setCodecs("avc1.42001e") + .setWidth(640) + .setHeight(360) + .build() + + assertTrue(support.rejectVideo(activeFormat)) + assertEquals(DecoderSupport.Unsupported, support.video(h264())) + } + @Test fun baselineH264DecoderCanStart() { val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 640, 360) 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 6d76f856..bb6f7605 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 @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredWidth import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -14,8 +17,11 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.ui.PlayerView import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.assertSame import org.junit.Rule import org.junit.Test @@ -56,6 +62,74 @@ class PlayerContentLayoutComposeTest { assertNodeCount(PLAYER_TWO_PANE_LAYOUT_TAG, 0) } + @Test + fun fullscreenLayoutRetainsTheNativePlayerView() { + var isFullscreen by mutableStateOf(false) + var createdViews = 0 + lateinit var firstView: PlayerView + lateinit var currentView: PlayerView + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerContentLayout( + isFullscreen = isFullscreen, + modifier = Modifier.fillMaxSize(), + viewport = { modifier -> + AndroidView( + factory = { context -> + PlayerView(context).also { + createdViews += 1 + if (createdViews == 1) firstView = it + } + }, + update = { currentView = it }, + modifier = modifier, + ) + }, + details = { Box(it.height(300.dp)) }, + ) + } + } + } + composeRule.waitForIdle() + assertEquals(1, createdViews) + + composeRule.runOnIdle { isFullscreen = true } + composeRule.waitForIdle() + assertEquals(1, createdViews) + assertSame(firstView, currentView) + + composeRule.runOnIdle { isFullscreen = false } + composeRule.waitForIdle() + assertEquals(1, createdViews) + } + + @Test + fun pictureInPictureShowsOnlyTheVideoAfterDetailsWereScrolled() { + var isInPip by mutableStateOf(false) + composeRule.setContent { + MaterialTheme { + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerContentLayout( + isFullscreen = false, + isInPip = isInPip, + modifier = Modifier.fillMaxSize(), + viewport = { Box(it.testTag(VIEWPORT_TAG)) }, + details = { Box(it.height(900.dp).testTag(DETAILS_TAG)) }, + ) + } + } + } + + composeRule.runOnIdle { isInPip = true } + composeRule.waitForIdle() + + val viewport = bounds(VIEWPORT_TAG) + assertEquals(0f, viewport.top, 1f) + assertEquals(600.dp.value, with(composeRule.density) { viewport.height.toDp().value }, 1f) + assertNodeCount(DETAILS_TAG, 0) + } + private fun setLayout(width: Dp, height: Dp) { composeRule.setContent { MaterialTheme { diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt index a63fc678..46202238 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerFullscreenEffectTest.kt @@ -43,6 +43,7 @@ class PlayerFullscreenEffectTest { activity = composeRule.activity, isFullscreen = fullscreen.value, locksLandscape = true, + restoresPortraitOnExit = true, ) } composeRule.waitForIdle() @@ -93,7 +94,7 @@ class PlayerFullscreenEffectTest { } composeRule.runOnIdle { assertEquals( - ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED, + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT, composeRule.activity.requestedOrientation, ) assertFalse(hasNoLimitsFlag()) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt index abe9666a..0a893835 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowTest.kt @@ -55,4 +55,29 @@ class PlayerInteractionRowTest { composeRule.onNodeWithContentDescription("Comments").assertDoesNotExist() } + + @Test + fun audioOnlyActionIsShownNextToCommentsWhenAvailable() { + val toggled = AtomicBoolean(false) + composeRule.setContent { + TypeTypeTheme { + PlayerInteractionRow( + isFavorited = false, + isInWatchLater = false, + shareUrl = "/watch?v=test", + onToggleFavorite = {}, + onToggleWatchLater = {}, + onAddToPlaylist = {}, + onShowComments = {}, + onDownload = {}, + audioOnlyAvailable = true, + onToggleAudioOnly = { toggled.set(true) }, + ) + } + } + + composeRule.onNodeWithContentDescription("Audio only").performClick() + + assertTrue(toggled.get()) + } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormatTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormatTest.kt new file mode 100644 index 00000000..3a995249 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormatTest.kt @@ -0,0 +1,98 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.TrackGroup +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlaybackActiveVideoFormatTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun activeLabelFollowsAdaptiveVideoSizeChanges() { + val player = AdaptiveFormatTestPlayer(Looper.getMainLooper()) + composeRule.setContent { + val active = rememberActiveVideoFormat(player) + Text(listOfNotNull(active?.qualityLabel, active?.codec).joinToString(" · ")) + } + + composeRule.onNodeWithText("720p · 30 fps · H.264").assertIsDisplayed() + composeRule.runOnIdle { player.selectHeight(1080) } + composeRule.onNodeWithText("1080p · 60 fps · AV1").assertIsDisplayed() + composeRule.runOnIdle { player.release() } + } +} + +private class AdaptiveFormatTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { + private var selectedHeight = 720 + private val tracks = adaptiveTracks() + + override fun getState(): State = State.Builder() + .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setVideoSize(VideoSize(if (selectedHeight == 720) 1280 else 1920, selectedHeight)) + .setPlaylist( + listOf( + MediaItemData.Builder("adaptive-item") + .setMediaItem(MediaItem.Builder().setMediaId("adaptive-item").build()) + .setTracks(tracks) + .build(), + ), + ) + .build() + + fun selectHeight(height: Int) { + selectedHeight = height + invalidateState() + } + + override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() +} + +private fun adaptiveTracks(): Tracks { + val h264 = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_H264) + .setCodecs("avc1.64001f") + .setWidth(1280) + .setHeight(720) + .setFrameRate(30f) + .setAverageBitrate(2_000_000) + .build() + val av1 = Format.Builder() + .setSampleMimeType(MimeTypes.VIDEO_AV1) + .setCodecs("av01.0.08M.08") + .setWidth(1920) + .setHeight(1080) + .setFrameRate(60f) + .setAverageBitrate(4_000_000) + .build() + return Tracks( + listOf( + Tracks.Group( + TrackGroup(h264, av1), + true, + intArrayOf(C.FORMAT_HANDLED, C.FORMAT_HANDLED), + booleanArrayOf(true, 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 new file mode 100644 index 00000000..5f968535 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -0,0 +1,79 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.reflect.Proxy +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlayerControlsLayoutTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun portraitControlsDoNotOverlapInsideShortVideoViewport() { + val player = controlsLayoutPlayer() + composeRule.setContent { + Box(Modifier.size(width = 360.dp, height = 202.dp)) { + PlayerControls( + player = player, + title = "Portrait controls", + onNavigateBack = {}, + isPipAvailable = true, + chaptersAvailable = true, + modifier = Modifier.matchParentSize(), + ) + } + } + + val top = composeRule.onNodeWithTag(PLAYER_TOP_CONTROLS_TAG, useUnmergedTree = true) + .fetchSemanticsNode() + .boundsInRoot + val center = composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG, useUnmergedTree = true) + .fetchSemanticsNode() + .boundsInRoot + val bottom = composeRule.onNodeWithTag(PLAYER_BOTTOM_CONTROLS_TAG, useUnmergedTree = true) + .fetchSemanticsNode() + .boundsInRoot + + assertTrue("Top controls overlap center controls", top.bottom <= center.top) + assertTrue("Center controls overlap bottom controls", center.bottom <= bottom.top) + } +} + +private fun controlsLayoutPlayer(): Player = Proxy.newProxyInstance( + Player::class.java.classLoader, + arrayOf(Player::class.java), +) { _, method, _ -> + when (method.name) { + "getApplicationLooper" -> Looper.getMainLooper() + "getAvailableCommands" -> Player.Commands.EMPTY + "getCurrentTimeline" -> Timeline.EMPTY + "getPlaybackParameters" -> PlaybackParameters.DEFAULT + "getCurrentTracks" -> Tracks.EMPTY + "getVideoSize" -> VideoSize.UNKNOWN + else -> when (method.returnType) { + Boolean::class.javaPrimitiveType -> false + Int::class.javaPrimitiveType -> 0 + Long::class.javaPrimitiveType -> 0L + Float::class.javaPrimitiveType -> 0f + Double::class.javaPrimitiveType -> 0.0 + else -> null + } + } +} as Player 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 new file mode 100644 index 00000000..4a7a7d9a --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt @@ -0,0 +1,202 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.doubleClick +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +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.SimpleBasePlayer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.typetype.android.feature.player.state.PlayerGestureState +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlayerGestureLayerTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun doubleTapZonesSeekTogglePlaybackAndProvideFeedback() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val feedbackCount = AtomicInteger() + composeRule.setContent { + MaterialTheme { + PlayerGestureLayer( + player = player, + state = PlayerGestureState(), + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + onGestureFeedback = { feedbackCount.incrementAndGet() }, + config = PlayerGestureConfig(doubleTapSeekSeconds = 5), + modifier = Modifier + .size(width = 300.dp, height = 180.dp) + .testTag(GESTURE_TAG), + ) + } + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + doubleClick(Offset(center.x * 5f / 3f, center.y)) + } + composeRule.runOnIdle { assertEquals(25_000L, player.currentPosition) } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + doubleClick(Offset(center.x / 3f, center.y)) + } + composeRule.runOnIdle { assertEquals(20_000L, player.currentPosition) } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { doubleClick(center) } + composeRule.runOnIdle { + assertTrue(player.isPlaying) + assertEquals(3, feedbackCount.get()) + player.release() + } + } + + @Test + fun enabledHorizontalSwipeSeeksOutsideFullscreen() { + val player = GestureTestPlayer(Looper.getMainLooper()) + composeRule.setContent { + MaterialTheme { + PlayerGestureLayer( + player = player, + state = PlayerGestureState(), + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + config = PlayerGestureConfig(swipeSeekEnabled = true), + modifier = Modifier + .size(width = 300.dp, height = 180.dp) + .testTag(GESTURE_TAG), + ) + } + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe( + start = Offset(60f, center.y), + end = Offset(240f, center.y), + durationMillis = 300L, + ) + } + composeRule.runOnIdle { + assertTrue(player.currentPosition > 20_000L) + player.release() + } + } + + @Test + fun fullscreenSwipeDownWorksThroughAnInteractiveControl() { + val exitCount = AtomicInteger() + val clickCount = AtomicInteger() + val feedbackCount = AtomicInteger() + composeRule.setContent { + MaterialTheme { + val state = rememberPlayerFullscreenExitGestureState() + Box( + Modifier + .size(width = 300.dp, height = 180.dp) + .playerFullscreenExitGesture( + enabled = true, + state = state, + onGestureFeedback = { feedbackCount.incrementAndGet() }, + onExitFullscreen = { exitCount.incrementAndGet() }, + ) + .testTag(FULLSCREEN_GESTURE_TAG), + ) { + Button( + onClick = { clickCount.incrementAndGet() }, + modifier = Modifier.align(Alignment.Center) + .size(width = 120.dp, height = 64.dp) + .testTag(INTERACTIVE_CONTROL_TAG), + ) { + Text("Play") + } + } + } + } + + composeRule.onNodeWithTag(FULLSCREEN_GESTURE_TAG).performTouchInput { + swipe( + start = center, + end = Offset(center.x, center.y + 240f), + durationMillis = 300L, + ) + } + + composeRule.runOnIdle { + assertEquals(1, exitCount.get()) + assertEquals(0, clickCount.get()) + assertEquals(1, feedbackCount.get()) + } + } + + private companion object { + const val GESTURE_TAG = "player_gesture_layer" + const val FULLSCREEN_GESTURE_TAG = "player_fullscreen_exit_gesture" + const val INTERACTIVE_CONTROL_TAG = "player_interactive_control" + } +} + +private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { + private var positionMs = 20_000L + private var playWhenReady = false + + override fun getState(): State = State.Builder() + .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlayWhenReady( + playWhenReady, + Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + ) + .setPlaylist( + listOf( + MediaItemData.Builder("gesture-item") + .setMediaItem(MediaItem.Builder().setMediaId("gesture-item").build()) + .build(), + ), + ) + .setContentPositionMs(positionMs) + .build() + + override fun handleSeek( + mediaItemIndex: Int, + positionMs: Long, + seekCommand: Int, + ): ListenableFuture<*> { + this.positionMs = positionMs + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + this.playWhenReady = playWhenReady + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerTimeBarGestureTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerTimeBarGestureTest.kt index 7524ff10..ff52d83e 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerTimeBarGestureTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerTimeBarGestureTest.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.test.down import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.moveTo import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -66,6 +68,22 @@ class PlayerTimeBarGestureTest { } } + @Test + fun accessibilityActionCommitsRequestedPosition() { + val scrubbed = mutableListOf() + val committed = mutableListOf() + showTimeline(scrubbed::add, committed::add) {} + + composeRule.onNodeWithTag(TIMELINE_TAG).performSemanticsAction( + SemanticsActions.SetProgress, + ) { setProgress -> setProgress(45_000f) } + + composeRule.runOnIdle { + assertEquals(45_000L, scrubbed.single()) + assertEquals(45_000L, committed.single()) + } + } + private fun showTimeline( onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, @@ -81,6 +99,8 @@ class PlayerTimeBarGestureTest { onScrub = onScrub, onScrubFinished = onScrubFinished, onScrubCancelled = onScrubCancelled, + accessibilityLabel = "Playback position", + accessibilityStateDescription = "0:10 of 1:00", modifier = Modifier .size(width = 240.dp, height = 36.dp) .testTag(TIMELINE_TAG), diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerFullscreenGestureIntegrationTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerFullscreenGestureIntegrationTest.kt new file mode 100644 index 00000000..d34b908e --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerFullscreenGestureIntegrationTest.kt @@ -0,0 +1,165 @@ +package dev.typetype.android.feature.player.host + +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.os.Looper +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +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.SimpleBasePlayer +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.typetype.android.feature.player.PLAYER_VIEWPORT_TAG +import dev.typetype.android.feature.player.FullscreenTestActivity +import dev.typetype.android.feature.player.PlayerContentLayout +import dev.typetype.android.feature.player.PlayerFullscreenEffect +import dev.typetype.android.feature.player.components.PlayerGestureLayer +import dev.typetype.android.feature.player.state.PlayerGestureState +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class PlayerFullscreenGestureIntegrationTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + private var target by mutableStateOf(PlayerHostTarget.Expanded) + private var isFullscreen by mutableStateOf(true) + private var requestStamp by mutableLongStateOf(0L) + private lateinit var player: FullscreenGesturePlayer + + @Before + fun forcePortraitAndShowPlayer() { + player = FullscreenGesturePlayer(Looper.getMainLooper()) + composeRule.activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + composeRule.waitUntil(TIMEOUT_MILLIS) { + composeRule.activity.resources.configuration.orientation == + Configuration.ORIENTATION_PORTRAIT + } + composeRule.setContent { + PlayerFullscreenEffect( + activity = composeRule.activity, + isFullscreen = isFullscreen, + locksLandscape = true, + restoresPortraitOnExit = true, + ) + val density = LocalDensity.current + BoxWithConstraints(Modifier.fillMaxSize()) { + val containerHeightPx = constraints.maxHeight.toFloat() + val miniHeightPx = with(density) { MINI_HEIGHT.toPx() } + PlayerHostMotionLayout( + target = target, + requestStamp = requestStamp, + miniAnchorPx = containerHeightPx - miniHeightPx, + containerHeightPx = containerHeightPx, + miniHeightPx = miniHeightPx, + dragEnabled = !isFullscreen, + miniContentEnabled = true, + onTargetSettled = { settledTarget -> + target = settledTarget + requestStamp += 1 + }, + onProgressChange = {}, + miniContent = { Box(Modifier.testTag(MINI_CONTENT_TAG)) }, + expandedContent = { transitionProgress -> + PlayerContentLayout( + isFullscreen = isFullscreen, + hostTransitionProgress = transitionProgress, + viewport = { viewportModifier -> + Box(viewportModifier) { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = isFullscreen, + onExitFullscreenGesture = { isFullscreen = false }, + modifier = Modifier.fillMaxSize().testTag(GESTURE_TAG), + ) + } + }, + details = { Box(it.height(240.dp).testTag(DETAILS_TAG)) }, + ) + }, + ) + } + } + composeRule.waitForIdle() + } + + @Test + fun swipeDownExitsFullscreenWithoutMinimizingThePlayer() { + composeRule.waitUntil(TIMEOUT_MILLIS) { + composeRule.activity.resources.configuration.orientation == + Configuration.ORIENTATION_LANDSCAPE + } + + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + swipe(center, center.copy(y = bottom - 1f), 700L) + } + + composeRule.waitUntil(TIMEOUT_MILLIS) { !isFullscreen } + composeRule.waitUntil(TIMEOUT_MILLIS) { + composeRule.activity.resources.configuration.orientation == + Configuration.ORIENTATION_PORTRAIT + } + + val hostBounds = composeRule.onNodeWithTag(PLAYER_HOST_OVERLAY_TAG) + .fetchSemanticsNode() + .boundsInRoot + val miniHeightPx = with(composeRule.density) { MINI_HEIGHT.toPx() } + assertEquals(PlayerHostTarget.Expanded, target) + org.junit.Assert.assertTrue(hostBounds.height > miniHeightPx * 2f) + val viewportBounds = composeRule.onNodeWithTag(PLAYER_VIEWPORT_TAG) + .fetchSemanticsNode() + .boundsInRoot + val detailsBounds = composeRule.onNodeWithTag(DETAILS_TAG) + .fetchSemanticsNode() + .boundsInRoot + assertEquals(16f / 9f, viewportBounds.width / viewportBounds.height, 0.01f) + assertEquals(viewportBounds.bottom, detailsBounds.top, 1f) + composeRule.runOnIdle { player.release() } + } + + private companion object { + val MINI_HEIGHT = 64.dp + const val MINI_CONTENT_TAG = "player_fullscreen_gesture_mini" + const val GESTURE_TAG = "player_fullscreen_gesture_layer" + const val DETAILS_TAG = "player_fullscreen_gesture_details" + const val TIMEOUT_MILLIS = 15_000L + } +} + +private class FullscreenGesturePlayer(looper: Looper) : SimpleBasePlayer(looper) { + override fun getState(): State = State.Builder() + .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlaylist( + listOf( + MediaItemData.Builder("fullscreen-gesture") + .setMediaItem(MediaItem.Builder().setMediaId("fullscreen-gesture").build()) + .build(), + ), + ) + .build() + + override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt index 1cbe902a..b66b4738 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayoutTest.kt @@ -1,7 +1,9 @@ package dev.typetype.android.feature.player.host +import android.os.Looper import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredWidth import androidx.compose.material3.Text @@ -9,9 +11,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -23,7 +27,20 @@ import androidx.compose.ui.test.moveTo import androidx.compose.ui.test.cancel import androidx.compose.ui.test.swipe import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.ui.PlayerView +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.typetype.android.feature.player.PlayerContentLayout +import dev.typetype.android.feature.player.PLAYER_VIEWPORT_TAG +import dev.typetype.android.feature.player.components.PlayerGestureLayer +import dev.typetype.android.feature.player.state.PlayerGestureState import org.junit.Assert.assertTrue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Rule import org.junit.Test @@ -37,6 +54,8 @@ class PlayerHostMotionLayoutTest { var settledTarget by mutableStateOf(PlayerHostTarget.Expanded) var requestStamp by mutableIntStateOf(0) var dragDistancePx = 0f + var crossedAnchors = 0 + var completedDrags = 0 composeRule.setContent { val density = LocalDensity.current dragDistancePx = with(density) { 500.dp.toPx() } @@ -54,8 +73,10 @@ class PlayerHostMotionLayoutTest { requestStamp += 1 }, onProgressChange = observedProgress::add, + onDragAnchorCrossed = { crossedAnchors += 1 }, + onDragSettled = { completedDrags += 1 }, miniContent = { Text("Mini content") }, - expandedContent = { Text("Expanded content", modifier = it) }, + expandedContent = { Text("Expanded content") }, ) } } @@ -66,6 +87,8 @@ class PlayerHostMotionLayoutTest { composeRule.waitUntil(5_000) { settledTarget == PlayerHostTarget.Mini } composeRule.onNodeWithText("Mini content").assertIsDisplayed() assertTrue(observedProgress.any { it in 0.1f..0.9f }) + assertEquals(1, crossedAnchors) + assertEquals(1, completedDrags) } @Test @@ -90,7 +113,7 @@ class PlayerHostMotionLayoutTest { }, onProgressChange = observedProgress::add, miniContent = { Text("Mini content") }, - expandedContent = { Text("Expanded content", modifier = it) }, + expandedContent = { Text("Expanded content") }, ) } } @@ -128,7 +151,7 @@ class PlayerHostMotionLayoutTest { onTargetSettled = settledTargets::add, onProgressChange = observedProgress::add, miniContent = { Text("Mini content") }, - expandedContent = { Text("Expanded content", modifier = it) }, + expandedContent = { Text("Expanded content") }, ) } } @@ -165,7 +188,7 @@ class PlayerHostMotionLayoutTest { onTargetSettled = {}, onProgressChange = observedProgress::add, miniContent = { Text("Mini content") }, - expandedContent = { Text("Expanded content", modifier = it) }, + expandedContent = { Text("Expanded content") }, ) } } @@ -179,4 +202,128 @@ class PlayerHostMotionLayoutTest { assertTrue(observedProgress.all { it < 0.01f }) composeRule.onNodeWithText("Expanded content").assertIsDisplayed() } + + @Test + fun realPlayerViewStaysAttachedThroughMiniAndBack() { + val viewportWidths = mutableStateListOf() + val player = HostSurfacePlayer(Looper.getMainLooper()) + var target by mutableStateOf(PlayerHostTarget.Expanded) + var requestStamp by mutableIntStateOf(0) + var createdViews = 0 + var expandedWidthPx = 0 + var miniWidthPx = 0 + var miniPositionMs = 0L + lateinit var firstView: PlayerView + + composeRule.setContent { + val density = LocalDensity.current + expandedWidthPx = with(density) { 400.dp.roundToPx() } + miniWidthPx = with(density) { 80.dp.roundToPx() } + Box(Modifier.requiredWidth(400.dp).requiredHeight(600.dp)) { + PlayerHostMotionLayout( + target = target, + requestStamp = requestStamp.toLong(), + miniAnchorPx = with(density) { 500.dp.toPx() }, + containerHeightPx = with(density) { 600.dp.toPx() }, + miniHeightPx = with(density) { 64.dp.toPx() }, + dragEnabled = true, + miniContentEnabled = true, + onTargetSettled = { + target = it + requestStamp += 1 + }, + onProgressChange = {}, + miniContent = { Text("Mini controls") }, + expandedContent = { transitionProgress -> + PlayerContentLayout( + isFullscreen = false, + hostTransitionProgress = transitionProgress, + modifier = Modifier.fillMaxSize(), + viewport = { modifier -> + Box(modifier) { + AndroidView( + factory = { context -> + PlayerView(context).apply { + useController = false + this.player = player + createdViews += 1 + if (createdViews == 1) firstView = this + } + }, + modifier = Modifier.fillMaxSize().onSizeChanged { + viewportWidths += it.width + }, + ) + if (transitionProgress() < 0.01f) { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = false, + ) + } + } + }, + details = { Box(it.requiredHeight(300.dp)) }, + ) + }, + ) + } + } + + composeRule.onNodeWithTag(PLAYER_HOST_OVERLAY_TAG).performTouchInput { + swipe(center, Offset(center.x, bottom - 1f), 800L) + } + composeRule.waitUntil(5_000) { target == PlayerHostTarget.Mini } + assertEquals(1, createdViews) + composeRule.runOnIdle { + assertSame(player, firstView.player) + miniPositionMs = player.currentPosition + assertTrue(miniPositionMs in 42_000L..60_000L) + } + assertTrue(viewportWidths.all { it in (expandedWidthPx - 2)..(expandedWidthPx + 2) }) + val miniVisualWidth = composeRule.onNodeWithTag(PLAYER_VIEWPORT_TAG) + .fetchSemanticsNode().boundsInRoot.width + assertTrue(miniVisualWidth in (miniWidthPx - 2f)..(miniWidthPx + 2f)) + + composeRule.onNodeWithText("Mini controls").performTouchInput { + swipe(center, Offset(center.x, center.y - 400f), 500L) + } + composeRule.waitUntil(5_000) { target == PlayerHostTarget.Expanded } + assertEquals(1, createdViews) + composeRule.runOnIdle { + assertSame(player, firstView.player) + assertTrue(player.currentPosition in miniPositionMs..(miniPositionMs + 10_000L)) + } + val expandedVisualWidth = composeRule.onNodeWithTag(PLAYER_VIEWPORT_TAG) + .fetchSemanticsNode().boundsInRoot.width + assertTrue(expandedVisualWidth in (expandedWidthPx - 2f)..(expandedWidthPx + 2f)) + composeRule.runOnIdle { player.release() } + } +} + +private class HostSurfacePlayer(looper: Looper) : SimpleBasePlayer(looper) { + override fun getState(): State = State.Builder() + .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlayWhenReady(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaylist( + listOf( + MediaItemData.Builder("host-surface") + .setMediaItem(MediaItem.Builder().setMediaId("host-surface").build()) + .build(), + ), + ) + .setContentPositionMs(42_000L) + .build() + + override fun handleSetVideoOutput(videoOutput: Any): ListenableFuture<*> = + Futures.immediateVoidFuture() + + override fun handleClearVideoOutput(videoOutput: Any?): ListenableFuture<*> = + Futures.immediateVoidFuture() + + override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() } 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 e4d9bb70..3b3447ad 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 @@ -117,8 +117,6 @@ class SettingsScreenAccessibilityTest { onOpenProfile = {}, onOpenImport = {}, importsAvailable = true, - onOpenYoutubeSession = {}, - youtubeSessionAvailable = true, onOpenRssFeeds = {}, rssAvailable = true, onOpenAccounts = { onOpenAccounts(true) }, diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt index c3811cc3..80b27254 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt @@ -1,14 +1,20 @@ package dev.typetype.android.feature.settings.appearance import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.isSelectable +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppPreferences +import dev.typetype.android.domain.preferences.AppearanceTheme import java.util.concurrent.atomic.AtomicReference import org.junit.Assert.assertEquals import org.junit.Rule @@ -20,17 +26,18 @@ class AppearanceScreenTest { @Test fun accentCellsExposeTheirLabelSelectionAndAction() { - val selected = AtomicReference() + val selected = AtomicReference() composeRule.setContent { TypeTypeTheme { AppearanceScreen( - currentAccent = AccentColor.Blue, - onAccentSelected = selected::set, + state = AppPreferences(accentColor = AccentColor.Blue), + onAction = selected::set, onNavigateBack = {}, ) } } + composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11) composeRule.onNode(isSelectable() and hasText("Blue")) .assertIsSelected() .assertHasClickAction() @@ -38,6 +45,28 @@ class AppearanceScreenTest { .assertIsNotSelected() .performClick() - assertEquals(AccentColor.Red, selected.get()) + assertEquals(AppearanceAction.SelectAccent(AccentColor.Red), selected.get()) + } + + @Test + fun komiColorThemesExposeAllChoices() { + val selected = AtomicReference() + composeRule.setContent { + TypeTypeTheme { + AppearanceScreen( + state = AppPreferences(), + onAction = selected::set, + onNavigateBack = {}, + ) + } + } + + composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(6) + listOf("TypeType", "Dynamic", "Nord", "Cream", "Forest", "Plum").forEach { name -> + composeRule.onNodeWithText(name).assertIsDisplayed() + } + composeRule.onNodeWithText("Forest").performClick() + + assertEquals(AppearanceAction.SelectTheme(AppearanceTheme.Forest), selected.get()) } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt index 023c619b..9eefa667 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/content/ContentSettingsScreenTest.kt @@ -59,19 +59,6 @@ class ContentSettingsScreenTest { composeRule.onAllNodes(isToggleable())[0].assertIsNotEnabled() } - @Test - fun hideEverythingRequiresConfirmationBeforeUpdatingTheServer() { - val action = AtomicReference() - show(ContentSettingsState(isLoading = false)) { action.set(it) } - - scrollTo("Hide everything") - composeRule.onNodeWithText("Hide everything").performClick() - composeRule.onNodeWithText("Hide every content surface?").assertIsDisplayed() - composeRule.onNodeWithText("Confirm").performClick() - - assertEquals(ContentSettingsAction.SetAllHidden(true), action.get()) - } - @Test fun updateFailureKeepsTheRequestIdReviewable() { show( diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/PortabilityPanelTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/PortabilityPanelTest.kt new file mode 100644 index 00000000..db6113af --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/PortabilityPanelTest.kt @@ -0,0 +1,97 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import dev.typetype.android.domain.imports.PortabilityCapability +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityFidelity +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.TypeTypeBackupCategory +import java.util.concurrent.atomic.AtomicReference +import org.junit.Rule +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PortabilityPanelTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun exportFormShowsFormatAssetsCategoriesAndTabs() { + val format = PortabilityFormat( + format = "typetype", + adapterVersion = 1, + capabilities = TypeTypeBackupCategory.entries.map { category -> + PortabilityCapability( + category = category, + directions = setOf(PortabilityDirection.Import, PortabilityDirection.Export), + fidelity = if (category == TypeTypeBackupCategory.History) { + PortabilityFidelity.Partial + } else { + PortabilityFidelity.Complete + }, + ) + }, + defaultExtension = "json", + contentType = "application/json", + ) + val selectedAll = AtomicReference>(emptySet()) + + composeRule.setContent { + var mode by remember { mutableStateOf(PortabilityScreenMode.Export) } + val selected = remember { mutableStateOf(setOf(TypeTypeBackupCategory.Subscriptions)) } + TypeTypeTheme { + PortabilityPanel( + state = PortabilityUiState( + mode = mode, + formats = listOf(format), + selectedFormat = format, + selectedCategories = selected.value, + ), + onModeSelected = { mode = it }, + onFormatSelected = {}, + onSelectAllCategories = selectedAll::set, + onCategoryToggled = { category -> + selected.value = selected.value.toMutableSet().apply { + if (!add(category)) remove(category) + } + }, + onPolicySelected = {}, + onStartExport = {}, + onChooseFile = {}, + onApplyImport = {}, + onCancelJob = {}, + onResetJob = {}, + onDownloadArtifact = {}, + onDownloadReport = {}, + ) + } + } + + composeRule.onNodeWithText("Import").assertIsDisplayed() + composeRule.onNodeWithText("Export").assertIsDisplayed() + composeRule.onNodeWithText("Destination format").assertIsDisplayed() + composeRule.onNodeWithText(".json").assertIsDisplayed() + composeRule.onNodeWithText("Subscriptions").assertIsDisplayed() + composeRule.onNodeWithText("Select all").assertIsDisplayed().performClick() + composeRule.runOnIdle { + assertEquals(TypeTypeBackupCategory.entries.size, selectedAll.get().size) + } + composeRule.onNodeWithText("Generate export").assertExists() + + composeRule.onNode(hasText("Import")).performClick() + composeRule.onNodeWithText("Get your TypeType backup").assertIsDisplayed() + composeRule.onNodeWithText("Choose or drop a backup file").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt new file mode 100644 index 00000000..99204b88 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsPlaybackControlsTest.kt @@ -0,0 +1,72 @@ +package dev.typetype.android.feature.settings.player + +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerSettingsPlaybackControlsTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun defaultCodecCanBeForced() { + val action = AtomicReference() + showSettings(action) + + composeRule.onNodeWithText("Default codec").performClick() + composeRule.onNodeWithText("AV1").performClick() + + assertEquals(PlayerSettingsAction.SetPreferredCodec("av1"), action.get()) + } + + @Test + fun doubleTapSeekTimeOffersFiveSeconds() { + val action = AtomicReference() + showSettings(action) + + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Double-tap seek time")) + composeRule.onNodeWithText("Double-tap seek time").performClick() + composeRule.onNodeWithText("5 seconds").performClick() + + assertEquals(PlayerSettingsAction.SetDoubleTapSeekSeconds(5), action.get()) + } + + @Test + fun accessibleControlsCanReplaceGestureOnlyInteraction() { + val action = AtomicReference() + showSettings(action) + + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Accessible player controls")) + composeRule.onNodeWithText("Accessible player controls").performClick() + + assertEquals(PlayerSettingsAction.SetAccessibleControls(true), action.get()) + } + + private fun showSettings(action: AtomicReference) { + composeRule.setContent { + TypeTypeTheme { + PlayerSettingsScreen( + state = PlayerSettingsState(), + onNavigateBack = {}, + onAction = action::set, + ) + } + } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreenTest.kt index c3c920dd..ba453494 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreenTest.kt @@ -70,6 +70,14 @@ class PrivacySettingsScreenTest { composeRule.onNodeWithText("The server could not refresh this value").assertIsDisplayed() } + @Test + fun searchHistoryDoesNotExposeAnUnavailableCount() { + show(PrivacyState()) + + composeRule.onNodeWithText("Clear saved searches from this account").assertIsDisplayed() + composeRule.onNodeWithText("Search history count unavailable").assertDoesNotExist() + } + private fun show( state: PrivacyState, onSetWatchHistoryTracking: (Boolean) -> Unit = {}, diff --git a/app/src/androidTest/java/dev/typetype/android/services/PlaybackLoadControlAndroidTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PlaybackLoadControlAndroidTest.kt index b527f562..4631bac5 100644 --- a/app/src/androidTest/java/dev/typetype/android/services/PlaybackLoadControlAndroidTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/services/PlaybackLoadControlAndroidTest.kt @@ -50,7 +50,7 @@ class PlaybackLoadControlAndroidTest { } @Test - fun timeThresholdOwnsStreamingBufferDecisions() { + fun byteBudgetStopsLoadingBelowTheDurationGoal() { val fixture = fixture() fixture.loadControl.onPrepared(fixture.playerId) fixture.loadControl.onTracksSelected( @@ -60,20 +60,15 @@ class PlaybackLoadControlAndroidTest { ) val allocator = fixture.loadControl.getAllocator(fixture.playerId) val allocations = buildList { - while (allocator.totalBytesAllocated < 32 * 1024 * 1024) { + while (allocator.totalBytesAllocated < 2 * 1024 * 1024) { add(allocator.allocate()) } } try { - assertTrue( - fixture.loadControl.shouldContinueLoading( - fixture.parameters(bufferedDurationUs = 10_000_000L), - ), - ) assertFalse( fixture.loadControl.shouldContinueLoading( - fixture.parameters(bufferedDurationUs = 30_000_000L), + fixture.parameters(bufferedDurationUs = 10_000_000L), ), ) } finally { @@ -93,7 +88,9 @@ class PlaybackLoadControlAndroidTest { MediaItem.fromUri("https://instance.test/media"), ) return Fixture( - loadControl = createPlaybackLoadControl(), + loadControl = createPlaybackLoadControl( + PlaybackBufferPolicy(targetBufferBytes = 2 * 1024 * 1024), + ), playerId = playerId, timeline = timeline, periodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0)), diff --git a/app/src/benchmarkRelease/AndroidManifest.xml b/app/src/benchmarkRelease/AndroidManifest.xml index 4bb2e2a6..89f2252d 100644 --- a/app/src/benchmarkRelease/AndroidManifest.xml +++ b/app/src/benchmarkRelease/AndroidManifest.xml @@ -4,5 +4,8 @@ + diff --git a/app/src/benchmarkRelease/java/dev/typetype/android/benchmark/PlayerHostBenchmarkActivity.kt b/app/src/benchmarkRelease/java/dev/typetype/android/benchmark/PlayerHostBenchmarkActivity.kt new file mode 100644 index 00000000..bf66616a --- /dev/null +++ b/app/src/benchmarkRelease/java/dev/typetype/android/benchmark/PlayerHostBenchmarkActivity.kt @@ -0,0 +1,185 @@ +package dev.typetype.android.benchmark + +import android.graphics.Color as AndroidColor +import android.graphics.Paint +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.VideoSize +import androidx.media3.ui.PlayerView +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import dev.typetype.android.feature.player.PlayerContentLayout +import dev.typetype.android.feature.player.host.PlayerHostMotionLayout +import dev.typetype.android.feature.player.host.PlayerHostTarget + +class PlayerHostBenchmarkActivity : ComponentActivity() { + private val player by lazy { BenchmarkSurfacePlayer(Looper.getMainLooper()) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + var target by mutableStateOf(PlayerHostTarget.Expanded) + var requestStamp by mutableLongStateOf(0L) + TypeTypeTheme { + BoxWithConstraints(Modifier.fillMaxSize()) { + val density = LocalDensity.current + val containerHeightPx = constraints.maxHeight.toFloat() + val miniHeightPx = with(density) { MINI_HEIGHT.toPx() } + PlayerHostMotionLayout( + target = target, + requestStamp = requestStamp, + miniAnchorPx = containerHeightPx - miniHeightPx, + containerHeightPx = containerHeightPx, + miniHeightPx = miniHeightPx, + dragEnabled = true, + miniContentEnabled = true, + onTargetSettled = { + target = it + requestStamp += 1 + }, + onProgressChange = {}, + miniContent = { Text("Benchmark mini player") }, + expandedContent = { transitionProgress -> + PlayerContentLayout( + isFullscreen = false, + hostTransitionProgress = transitionProgress, + modifier = Modifier.fillMaxSize(), + viewport = { modifier -> + AndroidView( + factory = { context -> + PlayerView(context).apply { + useController = false + setShutterBackgroundColor(AndroidColor.TRANSPARENT) + setKeepContentOnPlayerReset(true) + this.player = this@PlayerHostBenchmarkActivity.player + } + }, + modifier = modifier.background(Color.Black), + ) + }, + details = { Box(it.requiredHeight(360.dp)) }, + ) + }, + ) + } + } + } + } + + override fun onDestroy() { + player.release() + super.onDestroy() + } + + private companion object { + val MINI_HEIGHT = 64.dp + } +} + +private class BenchmarkSurfacePlayer(looper: Looper) : SimpleBasePlayer(looper) { + private val handler = Handler(looper) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private var outputSurface: Surface? = null + private var renderedFrame = 0 + private var surfaceGeneration = 0 + private val renderFrame = object : Runnable { + override fun run() { + val surface = outputSurface ?: return + if (!surface.isValid) { + handler.postDelayed(this, FRAME_DELAY_MS) + return + } + val canvas = surface.lockCanvas(null) + try { + val phase = renderedFrame % FRAME_PERIOD + canvas.drawColor(AndroidColor.rgb(12, 18, 30)) + paint.color = AndroidColor.rgb(83, 109, 254) + val left = canvas.width * phase.toFloat() / FRAME_PERIOD + canvas.drawRect(left, 0f, left + canvas.width / 4f, canvas.height.toFloat(), paint) + paint.color = AndroidColor.WHITE + paint.textSize = canvas.height.coerceAtMost(canvas.width) / 5f + canvas.drawText( + "$surfaceGeneration:$renderedFrame", + 32f, + canvas.height * 0.72f, + paint, + ) + } finally { + surface.unlockCanvasAndPost(canvas) + } + renderedFrame += 1 + handler.postDelayed(this, FRAME_DELAY_MS) + } + } + + override fun getState(): State = State.Builder() + .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) + .setPlaybackState(Player.STATE_READY) + .setPlayWhenReady(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setVideoSize(VideoSize(1920, 1080)) + .setNewlyRenderedFirstFrame(true) + .setPlaylist( + listOf( + MediaItemData.Builder("benchmark-surface") + .setMediaItem(MediaItem.Builder().setMediaId("benchmark-surface").build()) + .build(), + ), + ) + .setContentPositionMs(42_000L) + .build() + + override fun handleSetVideoOutput(videoOutput: Any): ListenableFuture<*> { + surfaceGeneration += 1 + outputSurface = when (videoOutput) { + is Surface -> videoOutput + is SurfaceHolder -> videoOutput.surface + is SurfaceView -> videoOutput.holder.surface + else -> null + } + handler.removeCallbacks(renderFrame) + handler.post(renderFrame) + return Futures.immediateVoidFuture() + } + + override fun handleClearVideoOutput(videoOutput: Any?): ListenableFuture<*> { + handler.removeCallbacks(renderFrame) + outputSurface = null + return Futures.immediateVoidFuture() + } + + override fun handleRelease(): ListenableFuture<*> { + handler.removeCallbacks(renderFrame) + outputSurface = null + return Futures.immediateVoidFuture() + } + + private companion object { + const val FRAME_DELAY_MS = 33L + const val FRAME_PERIOD = 90 + } +} diff --git a/app/src/main/assets/portability-formats/flow.png b/app/src/main/assets/portability-formats/flow.png new file mode 100644 index 00000000..7fe49a14 Binary files /dev/null and b/app/src/main/assets/portability-formats/flow.png differ diff --git a/app/src/main/assets/portability-formats/grayjay.png b/app/src/main/assets/portability-formats/grayjay.png new file mode 100644 index 00000000..32a3810e Binary files /dev/null and b/app/src/main/assets/portability-formats/grayjay.png differ diff --git a/app/src/main/assets/portability-formats/invidious.svg b/app/src/main/assets/portability-formats/invidious.svg new file mode 100644 index 00000000..1af16aa5 --- /dev/null +++ b/app/src/main/assets/portability-formats/invidious.svg @@ -0,0 +1 @@ +Invidious \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/libretube.svg b/app/src/main/assets/portability-formats/libretube.svg new file mode 100644 index 00000000..567310c5 --- /dev/null +++ b/app/src/main/assets/portability-formats/libretube.svg @@ -0,0 +1 @@ +LibreTube \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/materialious.png b/app/src/main/assets/portability-formats/materialious.png new file mode 100644 index 00000000..8980240b Binary files /dev/null and b/app/src/main/assets/portability-formats/materialious.png differ diff --git a/app/src/main/assets/portability-formats/newpipe.svg b/app/src/main/assets/portability-formats/newpipe.svg new file mode 100644 index 00000000..3a6a0aa6 --- /dev/null +++ b/app/src/main/assets/portability-formats/newpipe.svg @@ -0,0 +1 @@ +NewPipe \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/piped.svg b/app/src/main/assets/portability-formats/piped.svg new file mode 100644 index 00000000..4ade82c1 --- /dev/null +++ b/app/src/main/assets/portability-formats/piped.svg @@ -0,0 +1 @@ +Piped \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/pipepipe.png b/app/src/main/assets/portability-formats/pipepipe.png new file mode 100644 index 00000000..a783bfec Binary files /dev/null and b/app/src/main/assets/portability-formats/pipepipe.png differ diff --git a/app/src/main/assets/portability-formats/rss.svg b/app/src/main/assets/portability-formats/rss.svg new file mode 100644 index 00000000..14a6f3f7 --- /dev/null +++ b/app/src/main/assets/portability-formats/rss.svg @@ -0,0 +1 @@ +RSS \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/skytube.png b/app/src/main/assets/portability-formats/skytube.png new file mode 100644 index 00000000..bf06d57f Binary files /dev/null and b/app/src/main/assets/portability-formats/skytube.png differ diff --git a/app/src/main/assets/portability-formats/typetype.svg b/app/src/main/assets/portability-formats/typetype.svg new file mode 100644 index 00000000..2d412a28 --- /dev/null +++ b/app/src/main/assets/portability-formats/typetype.svg @@ -0,0 +1 @@ + diff --git a/app/src/main/assets/portability-formats/viewtube.png b/app/src/main/assets/portability-formats/viewtube.png new file mode 100644 index 00000000..c850d3a1 Binary files /dev/null and b/app/src/main/assets/portability-formats/viewtube.png differ diff --git a/app/src/main/assets/portability-formats/youtube-local.ico b/app/src/main/assets/portability-formats/youtube-local.ico new file mode 100644 index 00000000..9d6417c6 Binary files /dev/null and b/app/src/main/assets/portability-formats/youtube-local.ico differ diff --git a/app/src/main/assets/portability-formats/youtube-takeout.svg b/app/src/main/assets/portability-formats/youtube-takeout.svg new file mode 100644 index 00000000..0492366a --- /dev/null +++ b/app/src/main/assets/portability-formats/youtube-takeout.svg @@ -0,0 +1 @@ +YouTube \ No newline at end of file diff --git a/app/src/main/assets/portability-formats/youtube.svg b/app/src/main/assets/portability-formats/youtube.svg new file mode 100644 index 00000000..0492366a --- /dev/null +++ b/app/src/main/assets/portability-formats/youtube.svg @@ -0,0 +1 @@ +YouTube \ No newline at end of file diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index 73eeeb0a..b8cfdcba 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -18,6 +18,7 @@ import dev.typetype.android.core.ui.branding.DeArrowBrandingViewModel import dev.typetype.android.core.ui.branding.LocalDeArrowBranding import dev.typetype.android.core.ui.components.resolveProfileAvatarUrl import dev.typetype.android.core.ui.navigation.AboutRoute +import dev.typetype.android.core.ui.navigation.LicensesRoute import dev.typetype.android.core.ui.navigation.AccountsRoute import dev.typetype.android.core.ui.navigation.AddServerRoute import dev.typetype.android.core.ui.navigation.BlockedSettingsRoute @@ -27,8 +28,6 @@ import dev.typetype.android.core.ui.navigation.PublicPlaylistRoute import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.navigation.AppearanceRoute -import dev.typetype.android.core.ui.navigation.ChannelRoute -import dev.typetype.android.core.ui.navigation.channelNavigationUrl import dev.typetype.android.core.ui.navigation.ContentSettingsRoute import dev.typetype.android.core.ui.navigation.HomeRoute import dev.typetype.android.core.ui.navigation.LoginRoute @@ -65,9 +64,14 @@ import kotlinx.coroutines.flow.collectLatest fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { val navController: NavHostController = rememberNavController() val playerHostController = remember { mainViewModel.playerHostController } - val serverBaseUrl by mainViewModel.currentServerBaseUrl.collectAsStateWithLifecycle() - val currentServerId by mainViewModel.currentServerId.collectAsStateWithLifecycle() + val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle() val currentProfile by mainViewModel.currentProfile.collectAsStateWithLifecycle() + val appPreferences by mainViewModel.preferences.collectAsStateWithLifecycle() + val navigationTransitionMillis = when (appPreferences.appearanceMotion) { + dev.typetype.android.domain.preferences.AppearanceMotion.Full -> 280 + dev.typetype.android.domain.preferences.AppearanceMotion.Subtle -> 140 + dev.typetype.android.domain.preferences.AppearanceMotion.Off -> 0 + } val notificationBadge by rememberNotificationBadge() val deArrowViewModel = hiltViewModel() val playerViewModel = hiltViewModel() @@ -111,15 +115,13 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { } val onOpenChannel: (String) -> Unit = { channelUrl -> - navController.navigate(ChannelRoute(channelUrl = channelNavigationUrl(channelUrl))) { - launchSingleTop = true - } + navController.navigateToChannel(channelUrl) } - val avatarUrl = remember(currentProfile, serverBaseUrl) { + val avatarUrl = remember(currentProfile, currentServer) { val p = currentProfile ?: return@remember null resolveProfileAvatarUrl( - serverBaseUrl = serverBaseUrl, + serverBaseUrl = currentServer?.baseUrl, avatarUrl = p.avatarUrl, avatarType = p.avatarType, avatarCode = p.avatarCode, @@ -129,7 +131,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { val avatarFallback = currentProfile?.publicUsername?.firstOrNull()?.toString() CompositionLocalProvider( - LocalServerBaseUrl provides serverBaseUrl, + LocalServerBaseUrl provides currentServer?.baseUrl, LocalDeArrowBranding provides deArrowEnvironment, ) { AppShell( @@ -145,6 +147,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { avatarUrl = avatarUrl, avatarFallbackLetter = avatarFallback, showShorts = !deArrowSettings.hideShorts, + accessiblePlayerControls = appPreferences.playerAccessibleControlsEnabled, onPlayVideo = onPlayVideo, onOpenChannel = onOpenChannel, onClosePlayback = mainViewModel::closePlayback, @@ -156,25 +159,25 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { enterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(280), + animationSpec = tween(navigationTransitionMillis), ) }, exitTransition = { slideOutOfContainer( AnimatedContentTransitionScope.SlideDirection.Left, - animationSpec = tween(280), + animationSpec = tween(navigationTransitionMillis), ) }, popEnterTransition = { slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(280), + animationSpec = tween(navigationTransitionMillis), ) }, popExitTransition = { slideOutOfContainer( AnimatedContentTransitionScope.SlideDirection.Right, - animationSpec = tween(280), + animationSpec = tween(navigationTransitionMillis), ) }, ) { @@ -259,7 +262,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { onAddInstance = { navController.navigate(AddServerRoute) }, ) } - profileDestinations(navController, currentServerId) + profileDestinations(navController, currentServer?.id) composable { AppearanceRouteScreen( onNavigateBack = { navController.popBackStack() }, @@ -298,6 +301,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { composable { AboutScreen( onNavigateBack = { navController.popBackStack() }, + onOpenLicenses = { navController.navigate(LicensesRoute) }, ) } channelAndPodcastDestinations( diff --git a/app/src/main/java/dev/typetype/android/AppShell.kt b/app/src/main/java/dev/typetype/android/AppShell.kt index f998a82c..d78af414 100644 --- a/app/src/main/java/dev/typetype/android/AppShell.kt +++ b/app/src/main/java/dev/typetype/android/AppShell.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -41,9 +42,19 @@ import dev.typetype.android.feature.player.host.PlayerHost import dev.typetype.android.feature.player.host.PlayerHostController import dev.typetype.android.feature.player.host.PlayerHostTarget +import kotlin.math.abs + private const val NAV_BAR_HEIGHT_DP = 80f private val WIDE_NAVIGATION_THRESHOLD = 600.dp +internal const val PLAYER_TRANSITION_STEP = 0.05f + +internal fun shouldReportPlayerProgress( + previous: Float, + next: Float, +): Boolean = next == 0f || next == 1f || + abs(previous.coerceIn(0f, 1f) - next.coerceIn(0f, 1f)) >= PLAYER_TRANSITION_STEP + @Composable fun AppShell( navController: NavHostController, @@ -59,6 +70,7 @@ fun AppShell( avatarUrl: String? = null, avatarFallbackLetter: String? = null, showShorts: Boolean = true, + accessiblePlayerControls: Boolean = false, onPlayVideo: (videoUrl: String) -> Unit, onOpenChannel: (channelUrl: String) -> Unit, onClosePlayback: () -> Unit, @@ -80,7 +92,7 @@ fun AppShell( ) var activeTabRoute by rememberSaveable { mutableStateOf(null) } var isPlayerFullscreen by remember { mutableStateOf(false) } - var playerTransitionProgress by remember { mutableStateOf(0f) } + var playerTransitionProgress by remember { mutableFloatStateOf(0f) } val playerHostState by playerHostController.state.collectAsStateWithLifecycle() val appChromeVisible = isAppChromeVisible(playerHostState.target, isPlayerFullscreen) val phoneChromeAlpha = playerPhoneChromeAlpha( @@ -184,10 +196,15 @@ fun AppShell( isFullscreen = isPlayerFullscreen, onFullscreenChange = { isPlayerFullscreen = it }, mediaController = mediaController, + accessibleControlsEnabled = accessiblePlayerControls, onOpenChannel = onOpenChannel, onOpenAccounts = onOpenAccounts, onClosePlayback = onClosePlayback, - onTransitionProgressChange = { playerTransitionProgress = it }, + onTransitionProgressChange = { progress -> + if (shouldReportPlayerProgress(playerTransitionProgress, progress)) { + playerTransitionProgress = progress + } + }, content = {}, ) } diff --git a/app/src/main/java/dev/typetype/android/ChannelNavigation.kt b/app/src/main/java/dev/typetype/android/ChannelNavigation.kt new file mode 100644 index 00000000..722ac50e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/ChannelNavigation.kt @@ -0,0 +1,9 @@ +package dev.typetype.android + +import androidx.navigation.NavHostController +import dev.typetype.android.core.ui.navigation.ChannelRoute +import dev.typetype.android.core.ui.navigation.channelNavigationUrl + +internal fun NavHostController.navigateToChannel(channelUrl: String) { + navigate(ChannelRoute(channelUrl = channelNavigationUrl(channelUrl))) +} diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt index 32ec41f6..0f60404f 100644 --- a/app/src/main/java/dev/typetype/android/MainActivity.kt +++ b/app/src/main/java/dev/typetype/android/MainActivity.kt @@ -87,7 +87,7 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { registerPipReceiver() setContent { val preferences by viewModel.preferences.collectAsStateWithLifecycle() - TypeTypeTheme(accentColor = preferences.accentColor) { + TypeTypeTheme(preferences = preferences) { val state by viewModel.state.collectAsStateWithLifecycle() val startRoute = state.startRoute val pendingCrashReport = state.pendingCrashReport diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt index b3ac3c5e..2fe55708 100644 --- a/app/src/main/java/dev/typetype/android/MainViewModel.kt +++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.typetype.android.data.network.AccessTokenStore import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.preferences.StartupLandingStore import dev.typetype.android.domain.actions.VideoActionsRepository import dev.typetype.android.domain.auth.AuthRepository import dev.typetype.android.domain.auth.SessionStatus @@ -19,6 +21,7 @@ import dev.typetype.android.domain.playback.PlaybackQueueRepository import dev.typetype.android.domain.playback.PlaybackQueueSnapshot import dev.typetype.android.domain.playback.PlaybackResume import dev.typetype.android.domain.profile.ProfileRepository +import dev.typetype.android.domain.server.Server import dev.typetype.android.domain.server.ServerRepository import dev.typetype.android.domain.subscriptions.SubscriptionsRepository import dev.typetype.android.domain.usersettings.UserSettings @@ -60,6 +63,7 @@ class MainViewModel @Inject constructor( private val subscriptionsRepository: SubscriptionsRepository, private val libraryRepository: LibraryRepository, private val activeAccountScope: ActiveAccountScope, + private val startupLandingStore: StartupLandingStore, private val playbackResumeRepository: PlaybackResumeRepository, private val playbackQueueRepository: PlaybackQueueRepository, private val crashReportRepository: CrashReportRepository, @@ -73,23 +77,14 @@ class MainViewModel @Inject constructor( val preferences: StateFlow = preferencesRepository.observe() .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = AppPreferences(), ) - val currentServerBaseUrl: StateFlow = serverRepository.observeCurrentServer() - .map { it?.baseUrl } + val currentServer: StateFlow = serverRepository.observeCurrentServer() .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = null, - ) - - val currentServerId: StateFlow = serverRepository.observeCurrentServer() - .map { it?.id } - .stateIn( - scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = null, ) @@ -97,7 +92,7 @@ class MainViewModel @Inject constructor( profileRepository.observe() .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = null, ) @@ -121,14 +116,41 @@ class MainViewModel @Inject constructor( activeAccountScope.observe().first()?.accountId } } - val sessionStatus = if (initial == null) { - SessionStatus.Invalid - } else { - withTimeoutOrNull(SESSION_VALIDATION_TIMEOUT_MS) { - authRepository.validateSession() - } ?: SessionStatus.Unknown + val accountScope = initial?.let { server -> + selectedAccountId?.let { accountId -> AccountScope(server.id, accountId) } + } + val cachedLandingPage = accountScope + ?.let { startupLandingStore.observeLandingPage(it).first() } + .orEmpty() + val hasPersistedSession = accountScope != null && + tokenStore.hasAccessToken(initial.id, selectedAccountId.orEmpty()) + val sessionStatus = when { + initial == null || !hasPersistedSession -> SessionStatus.Invalid + else -> SessionStatus.Unknown + } + val startRoute = startupRoute( + serverId = initial?.id, + accountId = selectedAccountId, + sessionStatus = sessionStatus, + defaultLandingPage = cachedLandingPage, + ) + _state.update { + it.copy( + isLoading = false, + startRoute = startRoute, + ) + } + + if (initial == null || !hasPersistedSession) { + if (initial != null) tokenStore.setAccessToken(initial.id, null) + return@launch } - val isAuthenticated = initial != null && sessionStatus != SessionStatus.Invalid + + val scope = requireNotNull(accountScope) + val refreshedSessionStatus = withTimeoutOrNull(SESSION_VALIDATION_TIMEOUT_MS) { + authRepository.validateSession() + } ?: SessionStatus.Unknown + val isAuthenticated = refreshedSessionStatus != SessionStatus.Invalid val initialSettings = if (isAuthenticated) { withTimeoutOrNull(USER_SETTINGS_TIMEOUT_MS) { userSettingsRepository.current().getOrNull() @@ -136,34 +158,30 @@ class MainViewModel @Inject constructor( } else { null } - if (initial != null && sessionStatus == SessionStatus.Invalid) { - tokenStore.setAccessToken(initial.id, null) + initialSettings?.let { settings -> + startupLandingStore.setLandingPage(scope, settings.defaultLandingPage) } - val startRoute = startupRoute( - serverId = initial?.id, - accountId = selectedAccountId, - sessionStatus = sessionStatus, - defaultLandingPage = initialSettings?.defaultLandingPage.orEmpty(), - ) val playbackRestore = if (isAuthenticated && initialSettings != null) { withTimeoutOrNull(PLAYBACK_RESTORE_TIMEOUT_MS) { loadPlaybackRestore(initialSettings) } } else null - _state.update { it.copy(isLoading = false, startRoute = startRoute) } - if (isAuthenticated) { - val externalUrl = pendingVideoRequest.setReady(true) - if (externalUrl == null) { - playbackRestore?.let(::applyPlaybackRestore) - } else { - playerHostController.openVideo(externalUrl) - } - launch { videoActionsRepository.refreshBlocked() } - if (initialSettings == null) launch { userSettingsRepository.refresh() } - launch { profileRepository.refresh() } - launch { subscriptionsRepository.refresh() } - launch { libraryRepository.resumePendingWrites() } + if (!isAuthenticated) { + tokenStore.setAccessToken(initial.id, null) + eventsChannel.send(MainEvent.NavigateToLogin(initial.id)) + return@launch } + val externalUrl = pendingVideoRequest.setReady(true) + if (externalUrl == null) { + playbackRestore?.let(::applyPlaybackRestore) + } else { + playerHostController.openVideo(externalUrl) + } + launch { videoActionsRepository.refreshBlocked() } + if (initialSettings == null) launch { userSettingsRepository.refresh() } + launch { profileRepository.refresh() } + launch { subscriptionsRepository.refresh() } + launch { libraryRepository.resumePendingWrites() } } } @@ -172,6 +190,7 @@ class MainViewModel @Inject constructor( const val SESSION_VALIDATION_TIMEOUT_MS = 6_000L const val USER_SETTINGS_TIMEOUT_MS = 4_000L const val PLAYBACK_RESTORE_TIMEOUT_MS = 4_000L + const val STOP_TIMEOUT_MILLIS = 5_000L } fun signOut() { diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index 8d43f6c3..ffe525fa 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -10,17 +10,17 @@ 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.core.ui.navigation.YoutubeSessionRoute import dev.typetype.android.feature.settings.SettingsRoute as SettingsRouteScreen -import dev.typetype.android.feature.settings.imports.ImportDataRoute as ImportDataRouteScreen +import dev.typetype.android.feature.settings.about.LicensesScreen +import dev.typetype.android.feature.settings.imports.PortabilityRoute as ImportDataRouteScreen import dev.typetype.android.feature.settings.rss.RssFeedsRoute as RssFeedsRouteScreen -import dev.typetype.android.feature.settings.youtubesession.YoutubeSessionRoute as YoutubeSessionRouteScreen internal fun NavGraphBuilder.settingsDestinations( navController: NavHostController, @@ -32,7 +32,6 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenAccounts = { navController.navigate(AccountsRoute) }, onOpenProfile = { navController.navigate(ProfileSettingsRoute) }, onOpenImport = { navController.navigate(ImportDataRoute) }, - onOpenYoutubeSession = { navController.navigate(YoutubeSessionRoute) }, onOpenRssFeeds = { navController.navigate(RssFeedsRoute) }, onOpenAppearance = { navController.navigate(AppearanceRoute) }, onOpenContent = { navController.navigate(ContentSettingsRoute) }, @@ -45,13 +44,13 @@ internal fun NavGraphBuilder.settingsDestinations( onSignOut = onSignOut, ) } - composable { - ImportDataRouteScreen( + composable { + LicensesScreen( onNavigateBack = { navController.popBackStack() }, ) } - composable { - YoutubeSessionRouteScreen( + composable { + ImportDataRouteScreen( onNavigateBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/dev/typetype/android/TypeTypeApp.kt b/app/src/main/java/dev/typetype/android/TypeTypeApp.kt index c7fb29ec..38a4b075 100644 --- a/app/src/main/java/dev/typetype/android/TypeTypeApp.kt +++ b/app/src/main/java/dev/typetype/android/TypeTypeApp.kt @@ -7,6 +7,8 @@ import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder +import coil3.memory.MemoryCache +import coil3.memoryCacheMaxSizePercentWhileInBackground import coil3.svg.SvgDecoder import dagger.hilt.android.HiltAndroidApp import androidx.hilt.work.HiltWorkerFactory @@ -32,15 +34,26 @@ class TypeTypeApp : Application(), SingletonImageLoader.Factory, Configuration.P .setWorkerFactory(workerFactory) .build() - override fun newImageLoader(context: PlatformContext): ImageLoader = - ImageLoader.Builder(context) - .components { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - add(AnimatedImageDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - add(SvgDecoder.Factory()) - } - .build() + override fun newImageLoader(context: PlatformContext): ImageLoader = createTypeTypeImageLoader(context) } + +internal fun createTypeTypeImageLoader(context: PlatformContext): ImageLoader = + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, IMAGE_MEMORY_CACHE_PERCENT) + .build() + } + .memoryCacheMaxSizePercentWhileInBackground(BACKGROUND_CACHE_RETAINED_PERCENT) + .components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(AnimatedImageDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + add(SvgDecoder.Factory()) + } + .build() + +private const val IMAGE_MEMORY_CACHE_PERCENT = 0.10 +private const val BACKGROUND_CACHE_RETAINED_PERCENT = 0.50 diff --git a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt index d3b5d80e..5300a143 100644 --- a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt +++ b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt @@ -19,6 +19,7 @@ import dev.typetype.android.data.diagnostics.LocalCrashReportRepository import dev.typetype.android.data.feed.HomeFeedRepositoryImpl import dev.typetype.android.data.library.OfflineLibraryRepository import dev.typetype.android.data.imports.RemoteImportRepository +import dev.typetype.android.data.imports.RemotePortabilityRepository import dev.typetype.android.data.imports.RoomYoutubeTakeoutImportRepository import dev.typetype.android.data.library.RoomVideoMetaRepository import dev.typetype.android.data.notifications.RemoteNotificationsRepository @@ -66,6 +67,7 @@ import dev.typetype.android.domain.diagnostics.CrashReportRepository import dev.typetype.android.domain.feed.HomeFeedRepository import dev.typetype.android.domain.library.LibraryRepository import dev.typetype.android.domain.imports.ImportRepository +import dev.typetype.android.domain.imports.PortabilityRepository import dev.typetype.android.domain.imports.YoutubeTakeoutImportRepository import dev.typetype.android.domain.library.VideoMetaRepository import dev.typetype.android.domain.notifications.NotificationsRepository @@ -211,6 +213,12 @@ abstract class RepositoryModule { @Singleton abstract fun bindImportRepository(impl: RemoteImportRepository): ImportRepository + @Binds + @Singleton + abstract fun bindPortabilityRepository( + impl: RemotePortabilityRepository, + ): PortabilityRepository + @Binds @Singleton abstract fun bindYoutubeTakeoutImportRepository( diff --git a/app/src/main/java/dev/typetype/android/core/ui/branding/DeArrowBrandingViewModel.kt b/app/src/main/java/dev/typetype/android/core/ui/branding/DeArrowBrandingViewModel.kt index 7dc4c51b..fc40689d 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/branding/DeArrowBrandingViewModel.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/branding/DeArrowBrandingViewModel.kt @@ -20,10 +20,12 @@ class DeArrowBrandingViewModel @Inject constructor( val settings: StateFlow = userSettingsRepository.observe() .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = UserSettings(), ) suspend fun load(sourceUrl: String, durationSeconds: Long): Result = deArrowRepository.load(sourceUrl, durationSeconds) } + +private const val STOP_TIMEOUT_MILLIS = 5_000L diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/AnimatedStates.kt b/app/src/main/java/dev/typetype/android/core/ui/components/AnimatedStates.kt index 5045fb5f..39455e28 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/AnimatedStates.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/AnimatedStates.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -97,7 +96,7 @@ fun StreamErrorState( contentDescription = null, modifier = Modifier .size(220.dp) - .clip(RoundedCornerShape(20.dp)), + .clip(MaterialTheme.shapes.large), ) Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -137,7 +136,7 @@ fun StreamErrorState( verticalAlignment = Alignment.CenterVertically, ) { if (onRetry != null) { - ActionPill( + ErrorAction( label = retryLabel ?: stringResource(R.string.state_retry), background = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.background, @@ -145,7 +144,7 @@ fun StreamErrorState( ) } if (onBack != null) { - ActionPill( + ErrorAction( label = stringResource(R.string.state_go_back), background = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, @@ -176,7 +175,7 @@ fun AnimatedError( } @Composable -private fun ActionPill( +private fun ErrorAction( label: String, background: Color, contentColor: Color, @@ -184,7 +183,7 @@ private fun ActionPill( ) { Box( modifier = Modifier - .clip(RoundedCornerShape(999.dp)) + .clip(MaterialTheme.shapes.medium) .background(background) .clickable(onClick = onClick) .padding(horizontal = 18.dp, vertical = 10.dp), diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt index 1f635920..a0a73a57 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,6 +34,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt @@ -51,6 +52,7 @@ fun HorizontalVideoCard( ) { var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } + val serverBaseUrl = LocalServerBaseUrl.current val availability = video.availabilityAt(System.currentTimeMillis()) val branding = rememberVideoBranding( sourceUrl = video.url, @@ -80,11 +82,11 @@ fun HorizontalVideoCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(10.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -110,7 +112,7 @@ fun HorizontalVideoCard( } } AsyncImage( - model = video.uploaderAvatarUrl, + model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), contentDescription = if (onChannelClick != null) { stringResource(R.string.video_open_channel_accessibility, video.uploaderName) } else { diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/KomiStoreAttribution.kt b/app/src/main/java/dev/typetype/android/core/ui/components/KomiStoreAttribution.kt new file mode 100644 index 00000000..fe9a8721 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/KomiStoreAttribution.kt @@ -0,0 +1,45 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Composable +fun KomiStoreAttribution(modifier: Modifier = Modifier) { + val uriHandler = LocalUriHandler.current + Column(modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp)) { + Text( + text = stringResource(R.string.third_party_notice_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.third_party_komi_store_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = stringResource(R.string.third_party_komi_store_name), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 4.dp) + .clickable(role = Role.Button) { + uriHandler.openUri(KOMI_STORE_URL) + }, + ) + } +} + +private const val KOMI_STORE_URL = "https://github.com/kurikomi-labs/komi-store" diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/LibraryFilterBar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/LibraryFilterBar.kt index b0a8738d..816e1911 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/LibraryFilterBar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/LibraryFilterBar.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -67,7 +66,7 @@ fun LibraryFilterBar( Box( modifier = Modifier .weight(1f) - .clip(RoundedCornerShape(20.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 12.dp, vertical = 6.dp), ) { @@ -140,7 +139,7 @@ private fun SortMenu( onClick = { expanded = true }, modifier = Modifier .size(40.dp) - .clip(RoundedCornerShape(20.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surface), ) { Icon( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt index ddbb5d44..62fa31ca 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Icon @@ -35,6 +34,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.library.PlaylistVideo import dev.typetype.android.domain.library.VideoMeta @@ -50,6 +51,7 @@ fun PlaylistVideoCard( onChannelClick: ((channelUrl: String) -> Unit)? = null, onMoreClick: (() -> Unit)? = null, ) { + val serverBaseUrl = LocalServerBaseUrl.current val avatarUrl = video.channelAvatarUrl.takeIf { it.isNotBlank() } ?: meta?.channelAvatarUrl?.takeIf { it.isNotBlank() } val channelName = video.channelName.takeIf { it.isNotBlank() } @@ -79,11 +81,11 @@ fun PlaylistVideoCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(14.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -98,7 +100,7 @@ fun PlaylistVideoCard( modifier = Modifier .align(Alignment.TopStart) .padding(8.dp) - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(Color.Black.copy(alpha = 0.7f)) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -147,7 +149,7 @@ fun PlaylistVideoCard( } } AsyncImage( - model = avatarUrl, + model = avatarUrl?.let { buildImageUrl(serverBaseUrl, it) }, contentDescription = channelActionDescription, contentScale = ContentScale.Crop, modifier = avatarModifier, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ProfileAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ProfileAvatar.kt index d0e1999d..19e89ba4 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/ProfileAvatar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ProfileAvatar.kt @@ -20,10 +20,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext -import coil3.gif.repeatCount import coil3.request.ImageRequest import dev.typetype.android.core.openmoji.openMojiUrl import dev.typetype.android.core.openmoji.pickOpenMojiCode +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import java.net.URI @Composable @@ -35,6 +36,7 @@ fun ProfileAvatar( ) { var loaded by remember(imageUrl) { mutableStateOf(false) } val context = LocalPlatformContext.current + val serverBaseUrl = LocalServerBaseUrl.current Box( modifier = modifier .clip(CircleShape) @@ -55,8 +57,7 @@ fun ProfileAvatar( if (!imageUrl.isNullOrBlank()) { AsyncImage( model = ImageRequest.Builder(context) - .data(imageUrl) - .repeatCount(Int.MAX_VALUE) + .data(buildImageUrl(serverBaseUrl, imageUrl)) .build(), contentDescription = null, contentScale = ContentScale.Crop, 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 2268d8c5..884c7ecf 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 @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,6 +34,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt @@ -51,6 +52,7 @@ fun RelatedVideoCard( ) { var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } + val serverBaseUrl = LocalServerBaseUrl.current val availability = video.availabilityAt(System.currentTimeMillis()) val branding = rememberVideoBranding( sourceUrl = video.url, @@ -78,11 +80,11 @@ fun RelatedVideoCard( modifier = Modifier .width(148.dp) .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -109,7 +111,7 @@ fun RelatedVideoCard( ) Row(verticalAlignment = Alignment.CenterVertically) { AsyncImage( - model = video.uploaderAvatarUrl, + model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), contentDescription = stringResource( R.string.video_open_channel_accessibility, video.uploaderName, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SectionHeader.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SectionHeader.kt index 1b2b1df7..1a44ca8b 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/SectionHeader.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SectionHeader.kt @@ -1,17 +1,45 @@ package dev.typetype.android.core.ui.components +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.typetype.android.core.ui.theme.LocalTypeTypeAppearance +import dev.typetype.android.domain.preferences.MangaHeadlineMarker @Composable fun SectionHeader( text: String, modifier: Modifier = Modifier, ) { + val appearance = LocalTypeTypeAppearance.current + if (!appearance.isManga || appearance.headlineMarker == MangaHeadlineMarker.None) { + PlainSectionHeader(text, modifier) + return + } + when (appearance.headlineMarker) { + MangaHeadlineMarker.Stamp -> StampedSectionHeader(text, modifier, appearance.starburst) + MangaHeadlineMarker.SpeedLines -> SpeedLineSectionHeader(text, modifier, appearance.speedLines) + MangaHeadlineMarker.None -> Unit + } +} + +@Composable +private fun PlainSectionHeader(text: String, modifier: Modifier) { Text( text = text.uppercase(), modifier = modifier, @@ -22,3 +50,68 @@ fun SectionHeader( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + +@Composable +private fun StampedSectionHeader(text: String, modifier: Modifier, starburst: Boolean) { + Box(modifier = modifier, contentAlignment = Alignment.CenterStart) { + if (starburst) MangaStarburst(Modifier.size(48.dp).align(Alignment.CenterEnd)) + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + border = BorderStroke(2.dp, MaterialTheme.colorScheme.outline), + shape = MaterialTheme.shapes.extraSmall, + ) { + Text( + text = text.uppercase(), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Black, + letterSpacing = 1.sp, + ) + } + } +} + +@Composable +private fun SpeedLineSectionHeader(text: String, modifier: Modifier, speedLines: Boolean) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.onSurface, + ) + if (speedLines) MangaSpeedLines(Modifier.size(width = 54.dp, height = 18.dp)) + } +} + +@Composable +private fun MangaSpeedLines(modifier: Modifier) { + val color = MaterialTheme.colorScheme.onSurface + Canvas(modifier) { + repeat(5) { index -> + val y = size.height * (index + 1) / 6f + drawLine(color, Offset(0f, y), Offset(size.width * (1f - index * 0.08f), y), 2.dp.toPx()) + } + } +} + +@Composable +private fun MangaStarburst(modifier: Modifier) { + val color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + Canvas(modifier) { + val center = Offset(size.width / 2f, size.height / 2f) + repeat(12) { index -> + val angle = index * Math.PI.toFloat() / 6f + val end = Offset( + center.x + kotlin.math.cos(angle) * size.width / 2f, + center.y + kotlin.math.sin(angle) * size.height / 2f, + ) + drawLine(color, center, end, 1.5.dp.toPx()) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SettingsRows.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SettingsRows.kt index 36aaf49f..8e72ea15 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/SettingsRows.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SettingsRows.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.DropdownMenu @@ -23,6 +22,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -84,12 +84,39 @@ fun SwitchRow( ) } } - Switch(checked = checked, onCheckedChange = null, enabled = enabled) + TypeTypeSwitch(checked = checked, onCheckedChange = null, enabled = enabled) } SettingsRowDivider() } } +@Composable +fun TypeTypeSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + modifier = modifier, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + checkedBorderColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + uncheckedBorderColor = MaterialTheme.colorScheme.outline, + disabledCheckedThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledCheckedTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledUncheckedThumbColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + disabledUncheckedTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + ), + ) +} + @Composable fun DropdownRow( title: String, @@ -177,7 +204,7 @@ fun ServiceRow( verticalAlignment = Alignment.CenterVertically, ) { Box( - modifier = Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(brandColor), + modifier = Modifier.size(32.dp).clip(MaterialTheme.shapes.small).background(brandColor), contentAlignment = Alignment.Center, ) { Icon( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeButtons.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeButtons.kt index aacde1c7..38aad742 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeButtons.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeButtons.kt @@ -3,7 +3,7 @@ package dev.typetype.android.core.ui.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.BorderStroke import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -14,6 +14,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import dev.typetype.android.core.ui.theme.LocalTypeTypeAppearance @Composable fun TypeTypePrimaryButton( @@ -23,11 +24,13 @@ fun TypeTypePrimaryButton( enabled: Boolean = true, isLoading: Boolean = false, ) { + val appearance = LocalTypeTypeAppearance.current Button( onClick = onClick, modifier = modifier.fillMaxWidth(), enabled = enabled && !isLoading, - shape = RoundedCornerShape(10.dp), + shape = MaterialTheme.shapes.medium, + border = if (appearance.isManga) BorderStroke(2.dp, MaterialTheme.colorScheme.outline) else null, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, @@ -71,11 +74,13 @@ fun TypeTypeSecondaryButton( modifier: Modifier = Modifier, enabled: Boolean = true, ) { + val appearance = LocalTypeTypeAppearance.current OutlinedButton( onClick = onClick, modifier = modifier.fillMaxWidth(), enabled = enabled, - shape = RoundedCornerShape(10.dp), + shape = MaterialTheme.shapes.medium, + border = BorderStroke(if (appearance.isManga) 2.dp else 1.dp, MaterialTheme.colorScheme.outline), ) { Text(text = text, modifier = Modifier.padding(vertical = 4.dp)) } diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeCard.kt index 0e75b235..c3a3f732 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeCard.kt @@ -1,17 +1,21 @@ package dev.typetype.android.core.ui.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp +import dev.typetype.android.core.ui.theme.LocalTypeTypeAppearance @Composable fun TypeTypeCard( @@ -19,12 +23,30 @@ fun TypeTypeCard( contentPadding: PaddingValues = PaddingValues(24.dp), content: @Composable () -> Unit, ) { + val appearance = LocalTypeTypeAppearance.current Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + modifier = modifier.fillMaxWidth().rotate(if (appearance.isManga && appearance.panelTilt) -0.5f else 0f), + shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + border = BorderStroke(if (appearance.isManga) 2.dp else 1.dp, MaterialTheme.colorScheme.outline), ) { - Column(modifier = Modifier.padding(contentPadding), content = { content() }) + Box { + if (appearance.isManga && appearance.screentone) { + val tone = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) + Canvas(Modifier.matchParentSize()) { + val step = 9.dp.toPx() + var x = step / 2f + while (x < size.width) { + var y = step / 2f + while (y < size.height) { + drawCircle(tone, 0.8.dp.toPx(), Offset(x, y)) + y += step + } + x += step + } + } + } + Column(modifier = Modifier.padding(contentPadding), content = { content() }) + } } } 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 c0236fad..ac670f48 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 @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.Icon @@ -42,6 +41,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt @@ -59,6 +60,7 @@ fun VideoCard( ) { var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } + val serverBaseUrl = LocalServerBaseUrl.current val availability = video.availabilityAt(System.currentTimeMillis()) val metadata = video.metadataText() val branding = rememberVideoBranding( @@ -85,11 +87,11 @@ fun VideoCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -122,7 +124,7 @@ fun VideoCard( } } AsyncImage( - model = video.uploaderAvatarUrl, + model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), contentDescription = if (onChannelClick != null) { stringResource(R.string.video_open_channel_accessibility, video.uploaderName) } else { @@ -185,7 +187,7 @@ fun VideoCard( private fun WatchedBadge(modifier: Modifier = Modifier) { Row( modifier = modifier - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(Color.Black.copy(alpha = 0.7f)) .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoDurationBadge.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoDurationBadge.kt index 566b75b1..a2fa186d 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoDurationBadge.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoDurationBadge.kt @@ -2,7 +2,6 @@ package dev.typetype.android.core.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -19,7 +18,7 @@ internal fun VideoDurationBadge(durationSeconds: Long, modifier: Modifier = Modi style = MaterialTheme.typography.labelSmall, color = Color.White, modifier = modifier - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(Color.Black.copy(alpha = 0.82f)) .padding(horizontal = 6.dp, vertical = 2.dp), ) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoMembershipBadge.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoMembershipBadge.kt index 1d99955c..eabd38fd 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoMembershipBadge.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoMembershipBadge.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -26,7 +25,7 @@ internal fun VideoMembershipBadge( ) { Row( modifier = modifier - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(Color.Black.copy(alpha = 0.86f)) .padding(horizontal = if (compact) 5.dp else 7.dp, vertical = 3.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoStatusBadge.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoStatusBadge.kt index 525c99d5..e11c5634 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoStatusBadge.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoStatusBadge.kt @@ -2,7 +2,6 @@ package dev.typetype.android.core.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,7 +34,7 @@ internal fun VideoStatusBadge( ), color = Color.White, modifier = modifier - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(status.backgroundColor()) .padding(horizontal = if (compact) 5.dp else 7.dp, vertical = 2.dp), ) diff --git a/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt b/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt index 44be3ce0..f0461723 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt @@ -59,9 +59,6 @@ data object ProfileSettingsRoute @Serializable data object ImportDataRoute -@Serializable -data object YoutubeSessionRoute - @Serializable data object RssFeedsRoute @@ -77,6 +74,9 @@ data object BlockedSettingsRoute @Serializable data object AboutRoute +@Serializable +data object LicensesRoute + @Serializable data class PlayerRoute(val videoUrl: String) diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 5796d0b5..5aef0f6c 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -2,6 +2,7 @@ package dev.typetype.android.core.ui.share import androidx.compose.runtime.compositionLocalOf import dev.typetype.android.domain.navigation.toPublicWatchParameter +import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -17,3 +18,28 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { val encoded = URLEncoder.encode(toPublicWatchParameter(videoUrl), StandardCharsets.UTF_8.toString()) return "$origin/watch?v=$encoded" } + +fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { + val source = imageUrl.trim().let { value -> + if (value.startsWith("httpss://")) "https://${value.removePrefix("httpss://")}" else value + } + val base = serverBaseUrl?.trim()?.trimEnd('/') + if (source.isBlank() || base.isNullOrBlank()) return source + + val origin = base.removeSuffix("/api") + if (source.startsWith('/')) return "$origin$source" + if (source.startsWith("$origin/")) return source + if (!source.startsWith("http://") && !source.startsWith("https://")) return source + if (!needsImageProxy(source)) return source + + val encoded = URLEncoder.encode(source, StandardCharsets.UTF_8.toString()) + return "$base/proxy?url=$encoded" +} + +private fun needsImageProxy(source: String): Boolean { + val host = runCatching { URI(source).host?.lowercase() }.getOrNull() ?: return false + return host.endsWith("ggpht.com") || + host.endsWith("googleusercontent.com") || + host.endsWith("hdslb.com") || + host.endsWith("ytimg.com") +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt b/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt new file mode 100644 index 00000000..2a4d26a5 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/theme/AppearanceColorSchemes.kt @@ -0,0 +1,229 @@ +package dev.typetype.android.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.MangaPaper + +internal fun accentColors(accentColor: AccentColor): Pair = when (accentColor) { + AccentColor.Red -> AccentRed to Red300 + AccentColor.Blue -> AccentBlue to Blue300 + AccentColor.Yellow -> AccentYellow to Color(0xFFFDE68A) + AccentColor.Green -> AccentGreen to Color(0xFF86EFAC) + AccentColor.Purple -> AccentPurple to Color(0xFFE9D5FF) + AccentColor.Violet -> AccentViolet to Color(0xFFC4B5FD) + AccentColor.Monochrome -> AccentMonochrome to Zinc300 + AccentColor.System -> AccentBlue to Blue300 +} + +internal fun classicDarkScheme(accent: Color, accentSoft: Color, amoled: Boolean): ColorScheme { + val background = if (amoled) Color.Black else Zinc950 + val surface = if (amoled) Color.Black else Zinc900 + return darkColorScheme( + primary = accent, + onPrimary = Zinc950, + primaryContainer = accent, + onPrimaryContainer = Zinc950, + secondary = accent, + secondaryContainer = Zinc800, + tertiary = accentSoft, + onTertiary = Zinc950, + background = background, + onBackground = Zinc100, + surface = surface, + onSurface = Zinc100, + surfaceVariant = Zinc800, + onSurfaceVariant = Zinc400, + surfaceContainerHigh = Zinc800, + surfaceContainerHighest = Zinc700, + outline = Zinc700, + outlineVariant = Zinc800, + error = Red400, + onError = White, + ) +} + +internal fun classicLightScheme(accent: Color, accentSoft: Color): ColorScheme = lightColorScheme( + primary = accent, + onPrimary = Zinc950, + primaryContainer = accentSoft, + onPrimaryContainer = Zinc950, + secondary = accent, + tertiary = accent, + background = Color(0xFFF7F7F8), + onBackground = Zinc950, + surface = White, + onSurface = Zinc950, + surfaceVariant = Color(0xFFE4E4E7), + onSurfaceVariant = Zinc700, + surfaceContainerHigh = Color(0xFFE4E4E7), + surfaceContainerHighest = Zinc300, + outline = Zinc500, + outlineVariant = Zinc300, + error = Red600, + onError = White, +) + +internal fun themedDarkScheme( + theme: AppearanceTheme, + accent: Color, + accentSoft: Color, + amoled: Boolean, +): ColorScheme { + val base = classicDarkScheme(accent, accentSoft, amoled) + if (theme == AppearanceTheme.TypeType || theme == AppearanceTheme.Dynamic || amoled) return base + val colors = themeSurfaceColors(theme) + return base.copy( + background = colors.darkBackground, + surface = colors.darkSurface, + surfaceVariant = colors.darkVariant, + surfaceContainerHigh = colors.darkVariant, + surfaceContainerHighest = colors.darkOutline, + outline = colors.darkOutline, + outlineVariant = colors.darkVariant, + ) +} + +internal fun themedLightScheme( + theme: AppearanceTheme, + accent: Color, + accentSoft: Color, +): ColorScheme { + val base = classicLightScheme(accent, accentSoft) + if (theme == AppearanceTheme.TypeType || theme == AppearanceTheme.Dynamic) return base + val colors = themeSurfaceColors(theme) + return base.copy( + background = colors.lightBackground, + surface = colors.lightSurface, + surfaceVariant = colors.lightVariant, + surfaceContainerHigh = colors.lightVariant, + surfaceContainerHighest = colors.lightOutline, + outline = colors.lightOutline, + outlineVariant = colors.lightOutline, + ) +} + +private data class ThemeSurfaceColors( + val lightBackground: Color, + val lightSurface: Color, + val lightVariant: Color, + val lightOutline: Color, + val darkBackground: Color, + val darkSurface: Color, + val darkVariant: Color, + val darkOutline: Color, +) + +private fun themeSurfaceColors(theme: AppearanceTheme): ThemeSurfaceColors = when (theme) { + AppearanceTheme.Nord -> ThemeSurfaceColors( + lightBackground = Color(0xFFECEFF4), + lightSurface = Color(0xFFFFFFFF), + lightVariant = Color(0xFFD8DEE9), + lightOutline = Color(0xFF8A96A8), + darkBackground = Color(0xFF2E3440), + darkSurface = Color(0xFF3B4252), + darkVariant = Color(0xFF434C5E), + darkOutline = Color(0xFF616E82), + ) + AppearanceTheme.Cream -> ThemeSurfaceColors( + lightBackground = Color(0xFFFFF7ED), + lightSurface = Color(0xFFFFFBEB), + lightVariant = Color(0xFFFED7AA), + lightOutline = Color(0xFFB45309), + darkBackground = Color(0xFF261A12), + darkSurface = Color(0xFF3B2418), + darkVariant = Color(0xFF51301D), + darkOutline = Color(0xFF9A6A4A), + ) + AppearanceTheme.Forest -> ThemeSurfaceColors( + lightBackground = Color(0xFFF0FDF4), + lightSurface = Color(0xFFFFFFFF), + lightVariant = Color(0xFFDCFCE7), + lightOutline = Color(0xFF4D7C5B), + darkBackground = Color(0xFF0B1F14), + darkSurface = Color(0xFF102A1B), + darkVariant = Color(0xFF173A25), + darkOutline = Color(0xFF5D9270), + ) + AppearanceTheme.Plum -> ThemeSurfaceColors( + lightBackground = Color(0xFFFAF5FF), + lightSurface = Color(0xFFFFFFFF), + lightVariant = Color(0xFFF3E8FF), + lightOutline = Color(0xFF8B5BA8), + darkBackground = Color(0xFF1D1028), + darkSurface = Color(0xFF2A1738), + darkVariant = Color(0xFF3B2050), + darkOutline = Color(0xFF9567AD), + ) + AppearanceTheme.TypeType, AppearanceTheme.Dynamic -> error("Theme has no static surfaces") +} + +internal fun mangaScheme( + paper: MangaPaper, + theme: AppearanceTheme, + accent: Color, + accentSoft: Color, + amoled: Boolean, + isDark: Boolean, +): ColorScheme { + val usesPaperSurfaces = theme == AppearanceTheme.TypeType || theme == AppearanceTheme.Dynamic + val darkMode = isDark || amoled + val darkSurface = if (usesPaperSurfaces) darkMode || paper != MangaPaper.Day else darkMode + val background = if (amoled) { + Color.Black + } else { + when (paper) { + MangaPaper.Day -> if (darkMode) Color(0xFF211F1A) else Color(0xFFFFFDF5) + MangaPaper.Night -> Color.Black + MangaPaper.Nord -> Color(0xFF2E3440) + } + } + val surface = if (amoled) { + Color.Black + } else { + when (paper) { + MangaPaper.Day -> if (darkMode) Color(0xFF302B23) else Color(0xFFFFFAE8) + MangaPaper.Night -> Color(0xFF121212) + MangaPaper.Nord -> Color(0xFF3B4252) + } + } + val ink = if (darkSurface) Color(0xFFF5F3EA) else Color(0xFF171717) + val base = if (darkSurface) { + themedDarkScheme(theme, accent, accentSoft, amoled = false) + } else { + themedLightScheme(theme, accent, accentSoft) + } + val activeBackground = if (amoled) Color.Black + else if (usesPaperSurfaces) background else base.background + val activeSurface = if (amoled) Color.Black + else if (usesPaperSurfaces) surface else base.surface + val activeVariant = if (usesPaperSurfaces) surface else base.surfaceVariant + return base.copy( + primary = accent, + onPrimary = Color(0xFF111111), + primaryContainer = accentSoft, + onPrimaryContainer = Color(0xFF111111), + secondary = accent, + onSecondary = Color(0xFF111111), + tertiary = accent, + background = activeBackground, + onBackground = ink, + surface = activeSurface, + onSurface = ink, + surfaceVariant = activeVariant, + onSurfaceVariant = ink.copy(alpha = 0.72f), + surfaceContainerHigh = activeVariant, + surfaceContainerHighest = activeVariant, + surfaceContainerLow = activeSurface, + surfaceContainerLowest = activeBackground, + surfaceDim = activeBackground, + surfaceBright = activeSurface, + outline = ink, + outlineVariant = ink.copy(alpha = 0.42f), + error = if (darkSurface) Red300 else Red600, + onError = if (darkSurface) Color.Black else White, + ) +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt b/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt index 802ff721..153524c9 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/theme/Theme.kt @@ -1,54 +1,129 @@ package dev.typetype.android.core.ui.theme +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import dev.typetype.android.domain.preferences.AccentColor - -private fun darkSchemeFor(accent: Color, accentSoft: Color) = darkColorScheme( - primary = accent, - onPrimary = Zinc950, - primaryContainer = accent, - onPrimaryContainer = Zinc950, - secondary = accent, - onSecondary = White, - secondaryContainer = Zinc800, - onSecondaryContainer = Zinc100, - tertiary = accentSoft, - onTertiary = Zinc950, - background = Zinc950, - onBackground = Zinc100, - surface = Zinc900, - onSurface = Zinc100, - surfaceVariant = Zinc800, - onSurfaceVariant = Zinc400, - surfaceContainerHigh = Zinc800, - surfaceContainerHighest = Zinc700, - outline = Zinc800, - outlineVariant = Zinc700, - error = Red400, - onError = White, - errorContainer = Red500, - onErrorContainer = White, -) - -private fun colorsFor(accentColor: AccentColor): Pair = when (accentColor) { - AccentColor.Red -> AccentRed to Red300 - AccentColor.Blue -> AccentBlue to Blue300 - AccentColor.Yellow -> AccentYellow to Color(0xFFFDE68A) - AccentColor.Green -> AccentGreen to Color(0xFF86EFAC) - AccentColor.Purple -> AccentPurple to Color(0xFFE9D5FF) - AccentColor.Violet -> AccentViolet to Color(0xFFC4B5FD) - AccentColor.Monochrome -> AccentMonochrome to Zinc300 - AccentColor.System -> AccentBlue to Blue300 -} +import dev.typetype.android.domain.preferences.AppearanceFont +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.AppPreferences +import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback @Composable fun TypeTypeTheme( - accentColor: AccentColor = AccentColor.Blue, + preferences: AppPreferences = AppPreferences(), content: @Composable () -> Unit, ) { - val (accent, accentSoft) = colorsFor(accentColor) - MaterialTheme(colorScheme = darkSchemeFor(accent, accentSoft), content = content) + val systemDark = isSystemInDarkTheme() + val effectiveAmoled = preferences.appearanceAmoled && + preferences.appearanceMode != AppearanceMode.Light + val dark = when (preferences.appearanceMode) { + AppearanceMode.Light -> false + AppearanceMode.Dark -> true + AppearanceMode.System -> effectiveAmoled || systemDark + } + val (accent, accentSoft) = accentColors(preferences.accentColor) + val context = LocalContext.current + val dynamic = preferences.appearancePersonality == AppearancePersonality.Classic && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + (preferences.appearanceTheme == AppearanceTheme.Dynamic || + (preferences.appearanceTheme == AppearanceTheme.TypeType && + preferences.accentColor == AccentColor.System)) + val colorScheme = when { + preferences.appearancePersonality == AppearancePersonality.Manga -> + mangaScheme( + preferences.mangaPaper, + preferences.appearanceTheme, + accent, + accentSoft, + amoled = effectiveAmoled, + isDark = dark, + ) + dark && effectiveAmoled -> themedDarkScheme( + preferences.appearanceTheme, + accent, + accentSoft, + amoled = effectiveAmoled, + ) + dynamic && dark -> dynamicDarkColorScheme(context) + dynamic -> dynamicLightColorScheme(context) + dark -> themedDarkScheme( + preferences.appearanceTheme, + accent, + accentSoft, + effectiveAmoled, + ) + else -> themedLightScheme(preferences.appearanceTheme, accent, accentSoft) + } + val appearance = preferences.toAppearance() + CompositionLocalProvider( + LocalTypeTypeAppearance provides appearance, + LocalAnimatedStatePlayback provides (appearance.transitionMillis > 0), + ) { + MaterialTheme( + colorScheme = colorScheme, + shapes = shapesFor(appearance), + typography = typographyFor(preferences.appearanceFont, appearance.isManga), + content = content, + ) + } +} + +private fun AppPreferences.toAppearance() = TypeTypeAppearance( + personality = appearancePersonality, + motion = appearanceMotion, + headlineMarker = mangaHeadlineMarker, + screentone = mangaScreentone, + speedLines = mangaSpeedLines, + starburst = mangaStarburst, + inkedIcons = mangaInkedIcons, + panelTilt = mangaPanelTilt, +) + +private fun shapesFor(appearance: TypeTypeAppearance): Shapes = if (appearance.isManga) { + Shapes( + extraSmall = RoundedCornerShape(0.dp), + small = RoundedCornerShape(0.dp), + medium = RoundedCornerShape(2.dp), + large = RoundedCornerShape(2.dp), + extraLarge = RoundedCornerShape(2.dp), + ) +} else { + Shapes() +} + +private fun typographyFor(font: AppearanceFont, manga: Boolean): Typography { + val default = Typography() + val displayFamily = when { + manga -> FontFamily.Monospace + font == AppearanceFont.Expressive -> FontFamily.Serif + else -> FontFamily.Default + } + val titleWeight = if (manga) FontWeight.Black else FontWeight.SemiBold + fun title(style: TextStyle) = style.copy(fontFamily = displayFamily, fontWeight = titleWeight) + return default.copy( + displayLarge = title(default.displayLarge), + displayMedium = title(default.displayMedium), + displaySmall = title(default.displaySmall), + headlineLarge = title(default.headlineLarge), + headlineMedium = title(default.headlineMedium), + headlineSmall = title(default.headlineSmall), + titleLarge = title(default.titleLarge), + titleMedium = title(default.titleMedium), + titleSmall = title(default.titleSmall), + ) } diff --git a/app/src/main/java/dev/typetype/android/core/ui/theme/TypeTypeAppearance.kt b/app/src/main/java/dev/typetype/android/core/ui/theme/TypeTypeAppearance.kt new file mode 100644 index 00000000..c2d48acd --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/theme/TypeTypeAppearance.kt @@ -0,0 +1,41 @@ +package dev.typetype.android.core.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import dev.typetype.android.domain.preferences.AppearanceMotion +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.MangaHeadlineMarker + +@Immutable +data class TypeTypeAppearance( + val personality: AppearancePersonality, + val motion: AppearanceMotion, + val headlineMarker: MangaHeadlineMarker, + val screentone: Boolean, + val speedLines: Boolean, + val starburst: Boolean, + val inkedIcons: Boolean, + val panelTilt: Boolean, +) { + val isManga: Boolean get() = personality == AppearancePersonality.Manga + + val transitionMillis: Int + get() = when (motion) { + AppearanceMotion.Full -> 360 + AppearanceMotion.Subtle -> 180 + AppearanceMotion.Off -> 0 + } +} + +val LocalTypeTypeAppearance = staticCompositionLocalOf { + TypeTypeAppearance( + personality = AppearancePersonality.Classic, + motion = AppearanceMotion.Subtle, + headlineMarker = MangaHeadlineMarker.None, + screentone = false, + speedLines = false, + starburst = false, + inkedIcons = false, + panelTilt = false, + ) +} diff --git a/app/src/main/java/dev/typetype/android/data/imports/RemotePortabilityRepository.kt b/app/src/main/java/dev/typetype/android/data/imports/RemotePortabilityRepository.kt new file mode 100644 index 00000000..9d72beb0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/imports/RemotePortabilityRepository.kt @@ -0,0 +1,162 @@ +package dev.typetype.android.data.imports + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.dto.PortabilityApplyRequestDto +import dev.typetype.android.data.network.dto.PortabilityExportRequestDto +import dev.typetype.android.data.network.dto.PortabilityJobDto +import dev.typetype.android.data.network.dto.toDomain +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.imports.ImportDocument +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.PortabilityJob +import dev.typetype.android.domain.imports.PortabilityRepository +import dev.typetype.android.domain.imports.TypeTypeBackupCategory +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import retrofit2.Response + +@Singleton +class RemotePortabilityRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, +) : PortabilityRepository { + override suspend fun formats(): Result> = request { + val response = it.portabilityFormats() + response.requireSuccessfulResponse() + response.body().orEmpty().map { format -> format.toDomain() } + } + + override suspend fun startExport( + format: String, + categories: Set, + ): Result = request { + require(categories.isNotEmpty()) { PORTABILITY_NO_CATEGORIES } + val response = it.startPortabilityExport( + request = PortabilityExportRequestDto( + format = format, + categories = categories.map(TypeTypeBackupCategory::wireName), + ), + ) + decoded(response) + } + + override suspend fun startImport( + document: ImportDocument, + format: String, + ): Result = request { + validatePortabilityDocument(document) + val response = it.startPortabilityImport( + format = format, + file = MultipartBody.Part.createFormData( + name = "file", + filename = document.displayName, + body = ContentUriRequestBody( + contentResolver = context.contentResolver, + uri = document.uri.toUri(), + mediaType = document.mediaType?.toMediaType() ?: "application/octet-stream".toMediaType(), + knownSize = document.sizeBytes, + maxBytes = MAX_PORTABILITY_UPLOAD_BYTES, + ), + ), + ) + decoded(response) + } + + override suspend fun job(jobId: String): Result = request { + decoded(it.portabilityJob(jobId)) + } + + override suspend fun applyImport( + jobId: String, + categories: Set, + duplicatePolicy: PortabilityDuplicatePolicy, + ): Result = request { + require(categories.isNotEmpty()) { PORTABILITY_NO_CATEGORIES } + decoded( + it.applyPortabilityImport( + jobId = jobId, + request = PortabilityApplyRequestDto( + categories = categories.map(TypeTypeBackupCategory::wireName), + duplicatePolicy = duplicatePolicy.wireName, + ), + ), + ) + } + + override suspend fun cancel(jobId: String): Result = request { + decoded(it.cancelPortabilityJob(jobId)) + } + + override suspend fun delete(jobId: String): Result = request { api -> + val response = api.deletePortabilityJob(jobId) + response.requireSuccessfulResponse() + } + + override suspend fun downloadArtifact( + jobId: String, + destinationUri: String, + ): Result = request { api -> + val scope = activeAccountScope.require() + val response = withContext(Dispatchers.IO) { api.portabilityArtifact(jobId) } + val body = response.body() ?: error("The instance returned an empty export") + response.requireSuccessfulResponse() + activeAccountScope.verify(scope) + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(destinationUri.toUri(), "wt")?.use { output -> + body.byteStream().use { input -> input.copyTo(output) } + } ?: error("BACKUP_DESTINATION_UNAVAILABLE") + } + activeAccountScope.verify(scope) + } + + override suspend fun downloadReport( + jobId: String, + destinationUri: String, + ): Result = request { api -> + val response = withContext(Dispatchers.IO) { api.portabilityReport(jobId) } + val payload = response.body()?.string() ?: error("The instance returned an empty report") + response.requireSuccessfulResponse() + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(destinationUri.toUri(), "wt") + ?.use { output -> output.write(payload.toByteArray()) } + ?: error("BACKUP_DESTINATION_UNAVAILABLE") + } + } + + private suspend fun request( + block: suspend (dev.typetype.android.data.network.TypeTypeApi) -> T, + ): Result = runCatching { + val scope = activeAccountScope.require() + withContext(Dispatchers.IO) { block(apiHolder.require(scope)) } + .also { activeAccountScope.verify(scope) } + } + + private fun decoded(response: Response): PortabilityJob { + response.requireSuccessfulResponse() + return response.body()?.toDomain() + ?: error("The instance returned an empty portability job") + } +} + +internal const val PORTABILITY_NO_CATEGORIES = "BACKUP_NO_CATEGORIES" + +internal const val MAX_PORTABILITY_UPLOAD_BYTES = 2L * 1024 * 1024 * 1024 + +internal fun validatePortabilityDocument(document: ImportDocument) { + require(document.sizeBytes == null || document.sizeBytes > 0L) { + "IMPORT_FILE_EMPTY" + } + require(document.sizeBytes == null || document.sizeBytes <= MAX_PORTABILITY_UPLOAD_BYTES) { + "IMPORT_FILE_TOO_LARGE" + } +} diff --git a/app/src/main/java/dev/typetype/android/data/network/AccessTokenStore.kt b/app/src/main/java/dev/typetype/android/data/network/AccessTokenStore.kt index f2d6b072..d50d8615 100644 --- a/app/src/main/java/dev/typetype/android/data/network/AccessTokenStore.kt +++ b/app/src/main/java/dev/typetype/android/data/network/AccessTokenStore.kt @@ -21,7 +21,7 @@ class AccessTokenStore @Inject constructor( ) : ScopedAccessTokenStore { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val aead: Aead = run { + private val aead: Aead by lazy { AeadConfig.register() AndroidKeysetManager.Builder() .withSharedPref(context, KEYSET_NAME, PREFS_NAME) @@ -44,6 +44,12 @@ class AccessTokenStore @Inject constructor( override fun getAccessToken(serverId: String, accountId: String): String? = readToken(serverId, accountId) + @Synchronized + fun hasAccessToken(serverId: String, accountId: String): Boolean = + prefs.contains(tokenKey(serverId, accountId)) || + prefs.contains(serverTokenKey(serverId)) || + prefs.contains(KEY_ACCESS_TOKEN) + @Synchronized fun setAccessToken(serverId: String, token: String?) { val accountId = accountScopeStore.getCurrentAccountId(serverId) ?: PENDING_ACCOUNT_ID @@ -94,7 +100,7 @@ class AccessTokenStore @Inject constructor( } private fun migrateServerToken(serverId: String, accountId: String): String? { - val oldKey = "access_token_$serverId" + val oldKey = serverTokenKey(serverId) val encoded = prefs.getString(oldKey, null) ?: return null val token = decrypt(encoded, "typetype.android:$serverId".toByteArray(Charsets.UTF_8)) prefs.edit { remove(oldKey) } @@ -122,6 +128,8 @@ class AccessTokenStore @Inject constructor( return "access_token_$digest" } + private fun serverTokenKey(serverId: String): String = "access_token_$serverId" + private fun associatedData(serverId: String, accountId: String): ByteArray = "typetype.android:$serverId:$accountId".toByteArray(Charsets.UTF_8) diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeImportApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeImportApi.kt index 51fbab17..ad7a5ca6 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeImportApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeImportApi.kt @@ -1,6 +1,10 @@ package dev.typetype.android.data.network import dev.typetype.android.data.network.dto.PipePipeRestoreSummaryDto +import dev.typetype.android.data.network.dto.PortabilityApplyRequestDto +import dev.typetype.android.data.network.dto.PortabilityExportRequestDto +import dev.typetype.android.data.network.dto.PortabilityFormatDto +import dev.typetype.android.data.network.dto.PortabilityJobDto import dev.typetype.android.data.network.dto.TypeTypeRestoreSummaryDto import dev.typetype.android.data.network.dto.YoutubeTakeoutCommitRequestDto import dev.typetype.android.data.network.dto.YoutubeTakeoutJobStatusDto @@ -10,9 +14,11 @@ import okhttp3.MultipartBody import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.Path +import retrofit2.http.Streaming import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Query @@ -23,6 +29,43 @@ interface TypeTypeImportApi { @Query("categories") categories: String, ): Response + @GET("portability/formats") + suspend fun portabilityFormats(): Response> + + @POST("portability/exports") + suspend fun startPortabilityExport( + @Body request: PortabilityExportRequestDto, + ): Response + + @Multipart + @POST("portability/imports") + suspend fun startPortabilityImport( + @Query("format") format: String, + @Part file: MultipartBody.Part, + ): Response + + @POST("portability/jobs/{jobId}/apply") + suspend fun applyPortabilityImport( + @Path("jobId") jobId: String, + @Body request: PortabilityApplyRequestDto, + ): Response + + @GET("portability/jobs/{jobId}") + suspend fun portabilityJob(@Path("jobId") jobId: String): Response + + @POST("portability/jobs/{jobId}/cancel") + suspend fun cancelPortabilityJob(@Path("jobId") jobId: String): Response + + @DELETE("portability/jobs/{jobId}") + suspend fun deletePortabilityJob(@Path("jobId") jobId: String): Response + + @Streaming + @GET("portability/jobs/{jobId}/artifact") + suspend fun portabilityArtifact(@Path("jobId") jobId: String): Response + + @GET("portability/jobs/{jobId}/report") + suspend fun portabilityReport(@Path("jobId") jobId: String): Response + @Multipart @POST("restore/typetype") suspend fun restoreTypeType( diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/ImportDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/ImportDto.kt index 9b2c0446..0327699b 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/ImportDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/ImportDto.kt @@ -26,3 +26,78 @@ fun PipePipeRestoreSummaryDto.toDomain() = PipePipeRestoreSummary( historyMinWatchedAt = historyMinWatchedAt, historyMaxWatchedAt = historyMaxWatchedAt, ) +@Serializable +data class PortabilityCapabilityDto( + val category: String, + val directions: List = emptyList(), + val fidelity: String = "complete", +) + +@Serializable +data class PortabilityFormatDto( + val format: String, + val adapterVersion: Int, + val capabilities: List = emptyList(), + val defaultExtension: String, + val contentType: String, +) + +@Serializable +data class PortabilityExportRequestDto( + val format: String, + val categories: List, +) + +@Serializable +data class PortabilityApplyRequestDto( + val categories: List, + val duplicatePolicy: String, +) + +@Serializable +data class PortabilityIssueDto( + val category: String? = null, + val code: String, + val message: String, + val count: Int = 1, +) + +@Serializable +data class PortabilityPreviewDto( + val detection: PortabilityDetectionDto? = null, + val counts: Map = emptyMap(), + val duplicates: Int = 0, + val issues: List = emptyList(), +) + +@Serializable +data class PortabilityDetectionDto( + val format: String, + val formatVersion: String? = null, + val adapterVersion: Int, + val confidence: Double, + val evidence: String, +) + +@Serializable +data class PortabilityProgressDto( + val phase: String? = null, + val unit: String? = null, + val processed: Int = 0, + val total: Int? = null, +) + +@Serializable +data class PortabilityJobDto( + val id: String, + val kind: String, + val state: String, + val createdAt: Long, + val updatedAt: Long, + val requestId: String? = null, + val preview: PortabilityPreviewDto? = null, + val result: Map? = null, + val progress: PortabilityProgressDto? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PortabilityMappers.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PortabilityMappers.kt new file mode 100644 index 00000000..29c94bf4 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/network/dto/PortabilityMappers.kt @@ -0,0 +1,98 @@ +package dev.typetype.android.data.network.dto + +import dev.typetype.android.domain.imports.PortabilityCapability +import dev.typetype.android.domain.imports.PortabilityDetection +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy +import dev.typetype.android.domain.imports.PortabilityFidelity +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.PortabilityIssue +import dev.typetype.android.domain.imports.PortabilityJob +import dev.typetype.android.domain.imports.PortabilityJobState +import dev.typetype.android.domain.imports.PortabilityPreview +import dev.typetype.android.domain.imports.PortabilityProgress +import dev.typetype.android.domain.imports.TypeTypeBackupCategory + +internal fun PortabilityFormatDto.toDomain(): PortabilityFormat = PortabilityFormat( + format = format, + adapterVersion = adapterVersion, + capabilities = capabilities.map { capability -> + PortabilityCapability( + category = wireCategory(capability.category), + directions = capability.directions.mapNotNull(::direction).toSet(), + fidelity = fidelity(capability.fidelity), + ) + }, + defaultExtension = defaultExtension, + contentType = contentType, +) + +internal fun PortabilityJobDto.toDomain() = PortabilityJob( + id = id, + kind = direction(kind), + state = state(state), + createdAtMillis = createdAt, + updatedAtMillis = updatedAt, + requestId = requestId, + preview = preview?.toDomain(), + result = result.orEmpty().mapNotNull { (wireName, count) -> + categories[wireName]?.let { it to count } + }.toMap(), + progress = progress?.let { + PortabilityProgress( + phase = it.phase, + unit = it.unit, + processed = it.processed, + total = it.total, + ) + }, + errorCode = errorCode, + errorMessage = errorMessage, +) + +private fun PortabilityPreviewDto.toDomain() = PortabilityPreview( + detection = detection?.let { + PortabilityDetection( + format = it.format, + formatVersion = it.formatVersion, + adapterVersion = it.adapterVersion, + confidence = it.confidence, + evidence = it.evidence, + ) + }, + counts = counts.mapNotNull { (wireName, count) -> + categories[wireName]?.let { it to count } + }.toMap(), + duplicates = duplicates, + issues = issues.map { + PortabilityIssue( + category = it.category?.let(categories::get), + code = it.code, + message = it.message, + count = it.count, + ) + }, +) + +internal fun direction(wireName: String): PortabilityDirection = + requireNotNull(PortabilityDirection.entries.firstOrNull { it.wireName == wireName }) { + PORTABILITY_CONTRACT_ERROR + } + +private fun state(wireName: String): PortabilityJobState = + requireNotNull(PortabilityJobState.entries.firstOrNull { it.wireName == wireName }) { + PORTABILITY_CONTRACT_ERROR + } + +private fun fidelity(wireName: String) = when (wireName) { + "complete" -> PortabilityFidelity.Complete + "partial" -> PortabilityFidelity.Partial + else -> throw IllegalStateException(PORTABILITY_CONTRACT_ERROR) +} + +private val categories = TypeTypeBackupCategory.entries.associateBy(TypeTypeBackupCategory::wireName) + +private fun wireCategory(wireName: String) = + requireNotNull(categories[wireName]) { PORTABILITY_CONTRACT_ERROR } + +private const val PORTABILITY_CONTRACT_ERROR = "The instance returned an unsupported portability value" diff --git a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt index d1fe5ad3..089f291a 100644 --- a/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/preferences/DataStorePreferencesRepository.kt @@ -8,7 +8,14 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceFont +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearanceMotion +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme import dev.typetype.android.domain.preferences.AppPreferences +import dev.typetype.android.domain.preferences.MangaHeadlineMarker +import dev.typetype.android.domain.preferences.MangaPaper import dev.typetype.android.domain.preferences.PreferencesRepository import javax.inject.Inject import javax.inject.Singleton @@ -25,12 +32,30 @@ class DataStorePreferencesRepository @Inject constructor( accentColor = prefs[KEY_ACCENT_COLOR] ?.let { runCatching { AccentColor.valueOf(it) }.getOrNull() } ?: AccentColor.Blue, + appearancePersonality = prefs.enum(KEY_APPEARANCE_PERSONALITY, AppearancePersonality.Classic), + appearanceMode = prefs.enum(KEY_APPEARANCE_MODE, AppearanceMode.System), + appearanceAmoled = prefs[KEY_APPEARANCE_AMOLED] ?: false, + appearanceTheme = prefs.enum(KEY_APPEARANCE_THEME, AppearanceTheme.TypeType), + appearanceFont = prefs.enum(KEY_APPEARANCE_FONT, AppearanceFont.System), + appearanceMotion = prefs.enum(KEY_APPEARANCE_MOTION, AppearanceMotion.Subtle), + mangaPaper = prefs.enum(KEY_MANGA_PAPER, MangaPaper.Day), + mangaHeadlineMarker = prefs.enum(KEY_MANGA_HEADLINE_MARKER, MangaHeadlineMarker.Stamp), + mangaScreentone = prefs[KEY_MANGA_SCREENTONE] ?: true, + mangaSpeedLines = prefs[KEY_MANGA_SPEED_LINES] ?: true, + mangaStarburst = prefs[KEY_MANGA_STARBURST] ?: true, + mangaInkedIcons = prefs[KEY_MANGA_INKED_ICONS] ?: true, + mangaPanelTilt = prefs[KEY_MANGA_PANEL_TILT] ?: false, playerDoubleTapSeekEnabled = prefs[KEY_PLAYER_DOUBLE_TAP_SEEK] ?: true, + playerDoubleTapSeekSeconds = (prefs[KEY_PLAYER_DOUBLE_TAP_SEEK_SECONDS] ?: 10) + .takeIf(ALLOWED_DOUBLE_TAP_SEEK_SECONDS::contains) ?: 10, + playerPreferredCodec = prefs[KEY_PLAYER_PREFERRED_CODEC] + ?.takeIf(ALLOWED_PLAYER_CODECS::contains) ?: "recommended", playerSwipeSeekEnabled = prefs[KEY_PLAYER_SWIPE_SEEK] ?: true, playerSwipeBrightnessVolumeEnabled = prefs[KEY_PLAYER_SWIPE_BRIGHT_VOL] ?: true, playerPlaybackBrightnessPercent = prefs[KEY_PLAYER_PLAYBACK_BRIGHTNESS] ?.coerceIn(0, 100), playerLongPressSpeedEnabled = prefs[KEY_PLAYER_LONG_PRESS_SPEED] ?: true, + playerAccessibleControlsEnabled = prefs[KEY_PLAYER_ACCESSIBLE_CONTROLS] ?: false, playerAutoplayEnabled = prefs[KEY_PLAYER_AUTOPLAY] ?: true, playerAutoplayCountdownSeconds = ( prefs[KEY_PLAYER_AUTOPLAY_COUNTDOWN] ?: DEFAULT_AUTOPLAY_COUNTDOWN_SECONDS @@ -47,10 +72,48 @@ class DataStorePreferencesRepository @Inject constructor( dataStore.edit { it[KEY_ACCENT_COLOR] = accentColor.name } } + override suspend fun setAppearancePersonality(personality: AppearancePersonality) = + store(KEY_APPEARANCE_PERSONALITY, personality.name) + + override suspend fun setAppearanceMode(mode: AppearanceMode) = store(KEY_APPEARANCE_MODE, mode.name) + + override suspend fun setAppearanceAmoled(enabled: Boolean) = store(KEY_APPEARANCE_AMOLED, enabled) + + override suspend fun setAppearanceTheme(theme: AppearanceTheme) = store(KEY_APPEARANCE_THEME, theme.name) + + override suspend fun setAppearanceFont(font: AppearanceFont) = store(KEY_APPEARANCE_FONT, font.name) + + override suspend fun setAppearanceMotion(motion: AppearanceMotion) = store(KEY_APPEARANCE_MOTION, motion.name) + + override suspend fun setMangaPaper(paper: MangaPaper) = store(KEY_MANGA_PAPER, paper.name) + + override suspend fun setMangaHeadlineMarker(marker: MangaHeadlineMarker) = + store(KEY_MANGA_HEADLINE_MARKER, marker.name) + + override suspend fun setMangaScreentone(enabled: Boolean) = store(KEY_MANGA_SCREENTONE, enabled) + + override suspend fun setMangaSpeedLines(enabled: Boolean) = store(KEY_MANGA_SPEED_LINES, enabled) + + override suspend fun setMangaStarburst(enabled: Boolean) = store(KEY_MANGA_STARBURST, enabled) + + override suspend fun setMangaInkedIcons(enabled: Boolean) = store(KEY_MANGA_INKED_ICONS, enabled) + + override suspend fun setMangaPanelTilt(enabled: Boolean) = store(KEY_MANGA_PANEL_TILT, enabled) + override suspend fun setPlayerDoubleTapSeekEnabled(enabled: Boolean) { dataStore.edit { it[KEY_PLAYER_DOUBLE_TAP_SEEK] = enabled } } + override suspend fun setPlayerDoubleTapSeekSeconds(seconds: Int) { + val value = seconds.takeIf(ALLOWED_DOUBLE_TAP_SEEK_SECONDS::contains) ?: 10 + dataStore.edit { it[KEY_PLAYER_DOUBLE_TAP_SEEK_SECONDS] = value } + } + + override suspend fun setPlayerPreferredCodec(codec: String) { + val value = codec.takeIf(ALLOWED_PLAYER_CODECS::contains) ?: "recommended" + dataStore.edit { it[KEY_PLAYER_PREFERRED_CODEC] = value } + } + override suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) { dataStore.edit { it[KEY_PLAYER_SWIPE_SEEK] = enabled } } @@ -67,6 +130,10 @@ class DataStorePreferencesRepository @Inject constructor( dataStore.edit { it[KEY_PLAYER_LONG_PRESS_SPEED] = enabled } } + override suspend fun setPlayerAccessibleControlsEnabled(enabled: Boolean) { + dataStore.edit { it[KEY_PLAYER_ACCESSIBLE_CONTROLS] = enabled } + } + override suspend fun setPlayerAutoplayEnabled(enabled: Boolean) { dataStore.edit { it[KEY_PLAYER_AUTOPLAY] = enabled } } @@ -100,13 +167,37 @@ class DataStorePreferencesRepository @Inject constructor( dataStore.edit { it[KEY_DANMAKU_SIZE] = size.coerceIn(0.5f, 2f) } } + private suspend fun store(key: Preferences.Key, value: String) { + dataStore.edit { it[key] = value } + } + + private suspend fun store(key: Preferences.Key, value: Boolean) { + dataStore.edit { it[key] = value } + } + private companion object { val KEY_ACCENT_COLOR = stringPreferencesKey("accent_color") + val KEY_APPEARANCE_PERSONALITY = stringPreferencesKey("appearance_personality") + val KEY_APPEARANCE_MODE = stringPreferencesKey("appearance_mode") + val KEY_APPEARANCE_AMOLED = booleanPreferencesKey("appearance_amoled") + val KEY_APPEARANCE_THEME = stringPreferencesKey("appearance_theme") + val KEY_APPEARANCE_FONT = stringPreferencesKey("appearance_font") + val KEY_APPEARANCE_MOTION = stringPreferencesKey("appearance_motion") + val KEY_MANGA_PAPER = stringPreferencesKey("manga_paper") + val KEY_MANGA_HEADLINE_MARKER = stringPreferencesKey("manga_headline_marker") + val KEY_MANGA_SCREENTONE = booleanPreferencesKey("manga_screentone") + val KEY_MANGA_SPEED_LINES = booleanPreferencesKey("manga_speed_lines") + val KEY_MANGA_STARBURST = booleanPreferencesKey("manga_starburst") + val KEY_MANGA_INKED_ICONS = booleanPreferencesKey("manga_inked_icons") + val KEY_MANGA_PANEL_TILT = booleanPreferencesKey("manga_panel_tilt") val KEY_PLAYER_DOUBLE_TAP_SEEK = booleanPreferencesKey("player_double_tap_seek") + val KEY_PLAYER_DOUBLE_TAP_SEEK_SECONDS = intPreferencesKey("player_double_tap_seek_seconds") + val KEY_PLAYER_PREFERRED_CODEC = stringPreferencesKey("player_preferred_codec") val KEY_PLAYER_SWIPE_SEEK = booleanPreferencesKey("player_swipe_seek") val KEY_PLAYER_SWIPE_BRIGHT_VOL = booleanPreferencesKey("player_swipe_bright_vol") val KEY_PLAYER_PLAYBACK_BRIGHTNESS = intPreferencesKey("player_playback_brightness") val KEY_PLAYER_LONG_PRESS_SPEED = booleanPreferencesKey("player_long_press_speed") + val KEY_PLAYER_ACCESSIBLE_CONTROLS = booleanPreferencesKey("player_accessible_controls") val KEY_PLAYER_AUTOPLAY = booleanPreferencesKey("player_autoplay") val KEY_PLAYER_AUTOPLAY_COUNTDOWN = intPreferencesKey("player_autoplay_countdown_seconds") val KEY_PLAYER_AUDIO_ONLY_PLAYBACK = booleanPreferencesKey("player_audio_only_playback") @@ -116,5 +207,12 @@ class DataStorePreferencesRepository @Inject constructor( val KEY_DANMAKU_SIZE = floatPreferencesKey("player_danmaku_size") const val DEFAULT_AUTOPLAY_COUNTDOWN_SECONDS = 10 const val MAX_AUTOPLAY_COUNTDOWN_SECONDS = 60 + val ALLOWED_DOUBLE_TAP_SEEK_SECONDS = setOf(5, 10, 15, 20, 30) + val ALLOWED_PLAYER_CODECS = setOf("recommended", "av1", "vp9", "h264") } } + +private inline fun > Preferences.enum( + key: Preferences.Key, + fallback: T, +): T = this[key]?.let { stored -> enumValues().firstOrNull { it.name == stored } } ?: fallback diff --git a/app/src/main/java/dev/typetype/android/data/preferences/StartupLandingStore.kt b/app/src/main/java/dev/typetype/android/data/preferences/StartupLandingStore.kt new file mode 100644 index 00000000..8259124a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/preferences/StartupLandingStore.kt @@ -0,0 +1,31 @@ +package dev.typetype.android.data.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.typetype.android.data.account.AccountScope +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +@Singleton +class StartupLandingStore @Inject constructor( + private val dataStore: DataStore, +) { + fun observeLandingPage(scope: AccountScope): Flow = dataStore.data.map { prefs -> + prefs[landingKey(scope)].orEmpty() + }.distinctUntilChanged() + + suspend fun setLandingPage(scope: AccountScope, landingPage: String) { + dataStore.edit { prefs -> + val key = landingKey(scope) + if (landingPage.isBlank()) prefs.remove(key) else prefs[key] = landingPage + } + } + + private fun landingKey(scope: AccountScope) = + stringPreferencesKey("startup_landing_${scope.serverId}_${scope.accountId}") +} diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index cecdb28c..27125201 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -212,7 +212,11 @@ internal class StreamRepositoryImpl @Inject constructor( url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url, mimeType = mimeType, codec = codec, - resolution = resolution, + resolution = buildString { + append("${height.coerceAtLeast(0)}p") + fps.takeIf { it > 0 }?.let { append(it) } + if (resolution.contains("HDR", ignoreCase = true)) append(" HDR") + }, width = width, height = height, fps = fps, diff --git a/app/src/main/java/dev/typetype/android/domain/imports/ImportModels.kt b/app/src/main/java/dev/typetype/android/domain/imports/ImportModels.kt index defdf130..f4842da8 100644 --- a/app/src/main/java/dev/typetype/android/domain/imports/ImportModels.kt +++ b/app/src/main/java/dev/typetype/android/domain/imports/ImportModels.kt @@ -20,6 +20,7 @@ data class PipePipeRestoreSummary( enum class TypeTypeBackupCategory(val wireName: String) { Subscriptions("subscriptions"), + SubscriptionGroups("subscriptionGroups"), History("history"), Playlists("playlists"), WatchLater("watchLater"), diff --git a/app/src/main/java/dev/typetype/android/domain/imports/PortabilityModels.kt b/app/src/main/java/dev/typetype/android/domain/imports/PortabilityModels.kt new file mode 100644 index 00000000..a9e7183b --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/imports/PortabilityModels.kt @@ -0,0 +1,94 @@ +package dev.typetype.android.domain.imports + +data class PortabilityCapability( + val category: TypeTypeBackupCategory, + val directions: Set, + val fidelity: PortabilityFidelity, +) + +data class PortabilityFormat( + val format: String, + val adapterVersion: Int, + val capabilities: List, + val defaultExtension: String, + val contentType: String, +) { + fun supports(direction: PortabilityDirection, category: TypeTypeBackupCategory) = + capabilities.any { + it.category == category && direction in it.directions + } +} + +enum class PortabilityDirection(val wireName: String) { + Export("export"), + Import("import"), +} + +enum class PortabilityFidelity(val wireName: String) { + Complete("complete"), + Partial("partial"), +} + +enum class PortabilityDuplicatePolicy(val wireName: String) { + Skip("skip"), + Replace("replace"), +} + +enum class PortabilityJobState(val wireName: String) { + Queued("queued"), + Analyzing("analyzing"), + Ready("ready"), + Applying("applying"), + Encoding("encoding"), + Completed("completed"), + Failed("failed"), + Cancelled("cancelled"), +} + +data class PortabilityDetection( + val format: String, + val formatVersion: String?, + val adapterVersion: Int, + val confidence: Double, + val evidence: String, +) + +data class PortabilityIssue( + val category: TypeTypeBackupCategory?, + val code: String, + val message: String, + val count: Int, +) + +data class PortabilityPreview( + val detection: PortabilityDetection?, + val counts: Map, + val duplicates: Int, + val issues: List, +) + +data class PortabilityProgress( + val phase: String?, + val unit: String?, + val processed: Int, + val total: Int?, +) + +data class PortabilityJob( + val id: String, + val kind: PortabilityDirection, + val state: PortabilityJobState, + val createdAtMillis: Long, + val updatedAtMillis: Long, + val requestId: String? = null, + val preview: PortabilityPreview? = null, + val result: Map = emptyMap(), + val progress: PortabilityProgress? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) { + val isTerminal: Boolean + get() = state == PortabilityJobState.Completed || + state == PortabilityJobState.Failed || + state == PortabilityJobState.Cancelled +} diff --git a/app/src/main/java/dev/typetype/android/domain/imports/PortabilityRepository.kt b/app/src/main/java/dev/typetype/android/domain/imports/PortabilityRepository.kt new file mode 100644 index 00000000..1cdf0c40 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/imports/PortabilityRepository.kt @@ -0,0 +1,28 @@ +package dev.typetype.android.domain.imports + +interface PortabilityRepository { + suspend fun formats(): Result> + + suspend fun startExport( + format: String, + categories: Set, + ): Result + + suspend fun startImport(document: ImportDocument, format: String): Result + + suspend fun job(jobId: String): Result + + suspend fun applyImport( + jobId: String, + categories: Set, + duplicatePolicy: PortabilityDuplicatePolicy, + ): Result + + suspend fun cancel(jobId: String): Result + + suspend fun delete(jobId: String): Result + + suspend fun downloadArtifact(jobId: String, destinationUri: String): Result + + suspend fun downloadReport(jobId: String, destinationUri: String): Result +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt index c75b78de..bd4257d2 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppPreferences.kt @@ -2,11 +2,27 @@ package dev.typetype.android.domain.preferences data class AppPreferences( val accentColor: AccentColor = AccentColor.Blue, + val appearancePersonality: AppearancePersonality = AppearancePersonality.Classic, + val appearanceMode: AppearanceMode = AppearanceMode.System, + val appearanceAmoled: Boolean = false, + val appearanceTheme: AppearanceTheme = AppearanceTheme.TypeType, + val appearanceFont: AppearanceFont = AppearanceFont.System, + val appearanceMotion: AppearanceMotion = AppearanceMotion.Subtle, + val mangaPaper: MangaPaper = MangaPaper.Day, + val mangaHeadlineMarker: MangaHeadlineMarker = MangaHeadlineMarker.Stamp, + val mangaScreentone: Boolean = true, + val mangaSpeedLines: Boolean = true, + val mangaStarburst: Boolean = true, + val mangaInkedIcons: Boolean = true, + val mangaPanelTilt: Boolean = false, val playerDoubleTapSeekEnabled: Boolean = true, + val playerDoubleTapSeekSeconds: Int = 10, + val playerPreferredCodec: String = "recommended", val playerSwipeSeekEnabled: Boolean = true, val playerSwipeBrightnessVolumeEnabled: Boolean = true, val playerPlaybackBrightnessPercent: Int? = null, val playerLongPressSpeedEnabled: Boolean = true, + val playerAccessibleControlsEnabled: Boolean = false, val playerAutoplayEnabled: Boolean = true, val playerAutoplayCountdownSeconds: Int = 10, val playerAudioOnlyPlayback: Boolean = false, diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceFont.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceFont.kt new file mode 100644 index 00000000..8937d079 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceFont.kt @@ -0,0 +1,6 @@ +package dev.typetype.android.domain.preferences + +enum class AppearanceFont { + System, + Expressive, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMode.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMode.kt new file mode 100644 index 00000000..2c7e0983 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMode.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.domain.preferences + +enum class AppearanceMode { + System, + Light, + Dark, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMotion.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMotion.kt new file mode 100644 index 00000000..524d3bf3 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceMotion.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.domain.preferences + +enum class AppearanceMotion { + Full, + Subtle, + Off, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppearancePersonality.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppearancePersonality.kt new file mode 100644 index 00000000..9147570f --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppearancePersonality.kt @@ -0,0 +1,6 @@ +package dev.typetype.android.domain.preferences + +enum class AppearancePersonality { + Classic, + Manga, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceTheme.kt b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceTheme.kt new file mode 100644 index 00000000..76e90849 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/AppearanceTheme.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.domain.preferences + +enum class AppearanceTheme { + TypeType, + Dynamic, + Nord, + Cream, + Forest, + Plum, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/MangaHeadlineMarker.kt b/app/src/main/java/dev/typetype/android/domain/preferences/MangaHeadlineMarker.kt new file mode 100644 index 00000000..7a8e3927 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/MangaHeadlineMarker.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.domain.preferences + +enum class MangaHeadlineMarker { + None, + Stamp, + SpeedLines, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/MangaPaper.kt b/app/src/main/java/dev/typetype/android/domain/preferences/MangaPaper.kt new file mode 100644 index 00000000..9b941a05 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/preferences/MangaPaper.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.domain.preferences + +enum class MangaPaper { + Day, + Night, + Nord, +} diff --git a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt index 8540928b..f5504d2d 100644 --- a/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/preferences/PreferencesRepository.kt @@ -5,11 +5,27 @@ import kotlinx.coroutines.flow.Flow interface PreferencesRepository { fun observe(): Flow suspend fun setAccentColor(accentColor: AccentColor) + suspend fun setAppearancePersonality(personality: AppearancePersonality) + suspend fun setAppearanceMode(mode: AppearanceMode) + suspend fun setAppearanceAmoled(enabled: Boolean) + suspend fun setAppearanceTheme(theme: AppearanceTheme) + suspend fun setAppearanceFont(font: AppearanceFont) + suspend fun setAppearanceMotion(motion: AppearanceMotion) + suspend fun setMangaPaper(paper: MangaPaper) + suspend fun setMangaHeadlineMarker(marker: MangaHeadlineMarker) + suspend fun setMangaScreentone(enabled: Boolean) + suspend fun setMangaSpeedLines(enabled: Boolean) + suspend fun setMangaStarburst(enabled: Boolean) + suspend fun setMangaInkedIcons(enabled: Boolean) + suspend fun setMangaPanelTilt(enabled: Boolean) suspend fun setPlayerDoubleTapSeekEnabled(enabled: Boolean) + suspend fun setPlayerDoubleTapSeekSeconds(seconds: Int) + suspend fun setPlayerPreferredCodec(codec: String) suspend fun setPlayerSwipeSeekEnabled(enabled: Boolean) suspend fun setPlayerSwipeBrightnessVolumeEnabled(enabled: Boolean) suspend fun setPlayerPlaybackBrightnessPercent(percent: Int) suspend fun setPlayerLongPressSpeedEnabled(enabled: Boolean) + suspend fun setPlayerAccessibleControlsEnabled(enabled: Boolean) suspend fun setPlayerAutoplayEnabled(enabled: Boolean) suspend fun setPlayerAutoplayCountdownSeconds(seconds: Int) suspend fun setPlayerAudioOnlyPlayback(enabled: Boolean) diff --git a/app/src/main/java/dev/typetype/android/domain/usersettings/QualityNames.kt b/app/src/main/java/dev/typetype/android/domain/usersettings/QualityNames.kt new file mode 100644 index 00000000..737ac2e7 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/usersettings/QualityNames.kt @@ -0,0 +1,12 @@ +package dev.typetype.android.domain.usersettings + +private val QUALITY_HEIGHT = Regex("""\d+""") + +fun normalizeQualityName(value: String): String { + val cleaned = value.trim() + val match = QUALITY_HEIGHT.find(cleaned) ?: return cleaned.ifBlank { "1080p" } + val height = "${match.value}p" + return if (QUALITY_HDR.containsMatchIn(cleaned)) "$height HDR" else height +} + +private val QUALITY_HDR = Regex("""\bHDR\b""", RegexOption.IGNORE_CASE) diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelDiscoveryControls.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelDiscoveryControls.kt index 3090c36b..2247c833 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelDiscoveryControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelDiscoveryControls.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ExpandMore @@ -22,6 +21,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -145,7 +145,7 @@ private fun ChannelSearchField( onSubmit() }, ), - shape = RoundedCornerShape(14.dp), + shape = MaterialTheme.shapes.large, modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), ) } @@ -164,7 +164,7 @@ private fun ChannelMenuButton( Box { OutlinedButton( onClick = { expanded = true }, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, modifier = Modifier.semantics { contentDescription = accessibilityLabel stateDescription = label 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 76730fe6..78b4e007 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 @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle @@ -38,6 +37,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.channel.Channel private val BannerAspectRatio = 16f / 6f @@ -51,6 +52,7 @@ internal fun ChannelHeader( onToggleSubscribe: () -> Unit, onNavigateBack: () -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current Column(modifier = Modifier.fillMaxWidth()) { if (channel.bannerUrl.isNullOrBlank()) { ChannelBackButton(onNavigateBack = onNavigateBack) @@ -62,7 +64,7 @@ internal fun ChannelHeader( .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = channel.bannerUrl, + model = buildImageUrl(serverBaseUrl, channel.bannerUrl.orEmpty()), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -76,7 +78,7 @@ internal fun ChannelHeader( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { AsyncImage( - model = channel.avatarUrl, + model = buildImageUrl(serverBaseUrl, channel.avatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -148,7 +150,7 @@ private fun SubscribeButton(isSubscribed: Boolean, enabled: Boolean, onClick: () Button( onClick = onClick, enabled = enabled, - shape = RoundedCornerShape(20.dp), + shape = MaterialTheme.shapes.large, colors = ButtonDefaults.buttonColors( containerColor = if (isSubscribed) { MaterialTheme.colorScheme.surfaceVariant diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelPodcastsSection.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelPodcastsSection.kt index 4ad2847c..6fec8d22 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelPodcastsSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelPodcastsSection.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -28,6 +27,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.podcast.Podcast @Composable @@ -36,6 +37,7 @@ fun ChannelPodcastsSection( isLoading: Boolean, onOpenPodcast: (String) -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -58,7 +60,11 @@ fun ChannelPodcastsSection( horizontalArrangement = Arrangement.spacedBy(14.dp), ) { items(podcasts, key = { it.url }) { podcast -> - PodcastCard(podcast, onClick = { onOpenPodcast(podcast.url) }) + PodcastCard( + podcast = podcast, + serverBaseUrl = serverBaseUrl, + onClick = { onOpenPodcast(podcast.url) }, + ) } } } @@ -66,16 +72,20 @@ fun ChannelPodcastsSection( } @Composable -private fun PodcastCard(podcast: Podcast, onClick: () -> Unit) { +private fun PodcastCard( + podcast: Podcast, + serverBaseUrl: String?, + onClick: () -> Unit, +) { Column( modifier = Modifier.width(168.dp).clickable(role = Role.Button, onClick = onClick), verticalArrangement = Arrangement.spacedBy(5.dp), ) { AsyncImage( - model = podcast.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, podcast.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(14.dp)), + modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(MaterialTheme.shapes.large), ) Text( text = podcast.title, diff --git a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt index c28286f1..74df4e9c 100644 --- a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +36,8 @@ import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.components.SectionHeader import dev.typetype.android.core.ui.components.VideoDurationBadge +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.library.HistoryItem @Composable @@ -46,6 +47,7 @@ internal fun ContinueWatchingSection( onOpenChannel: (String) -> Unit, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -62,6 +64,7 @@ internal fun ContinueWatchingSection( ) { item -> ContinueWatchingCard( item = item, + serverBaseUrl = serverBaseUrl, onClick = { onPlayVideo(item.url) }, onOpenChannel = onOpenChannel, ) @@ -73,6 +76,7 @@ internal fun ContinueWatchingSection( @Composable private fun ContinueWatchingCard( item: HistoryItem, + serverBaseUrl: String?, onClick: () -> Unit, onOpenChannel: (String) -> Unit, ) { @@ -91,11 +95,11 @@ private fun ContinueWatchingCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), @@ -122,7 +126,7 @@ private fun ContinueWatchingCard( Row(verticalAlignment = Alignment.CenterVertically) { if (item.channelAvatarUrl.isNotBlank()) { AsyncImage( - model = item.channelAvatarUrl, + model = buildImageUrl(serverBaseUrl, item.channelAvatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/java/dev/typetype/android/feature/library/HistoryDateFilterBar.kt b/app/src/main/java/dev/typetype/android/feature/library/HistoryDateFilterBar.kt index 39d62b33..56a27b38 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/HistoryDateFilterBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/HistoryDateFilterBar.kt @@ -1,8 +1,15 @@ package dev.typetype.android.feature.library +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons @@ -12,8 +19,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -25,7 +32,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.typetype.android.R import java.time.Instant @@ -56,7 +67,7 @@ internal fun HistoryDateFilterBar( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(options, key = { it.first.name }) { (option, label) -> - FilterChip( + HistoryFilterOption( selected = selection == option, onClick = { onSelectionChange( @@ -64,24 +75,18 @@ internal fun HistoryDateFilterBar( null, ) }, - label = { Text(label) }, + label = label, ) } item(key = "specific-date") { - FilterChip( + HistoryFilterOption( selected = selection == HistoryDateSelection.SpecificDay, onClick = { showDatePicker = true }, - leadingIcon = { - Icon(Icons.Outlined.CalendarMonth, contentDescription = null) - }, - label = { - Text( - selectedDateMillis?.takeIf { - selection == HistoryDateSelection.SpecificDay - }?.let(::formatHistoryDate) - ?: stringResource(R.string.library_history_choose_date), - ) - }, + icon = Icons.Outlined.CalendarMonth, + label = selectedDateMillis?.takeIf { + selection == HistoryDateSelection.SpecificDay + }?.let(::formatHistoryDate) + ?: stringResource(R.string.library_history_choose_date), ) } } @@ -154,6 +159,41 @@ internal fun HistoryDateFilterBar( } } +@Composable +private fun HistoryFilterOption( + selected: Boolean, + label: String, + icon: ImageVector? = null, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick, + ).padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { Icon(it, contentDescription = null) } + Text( + text = label, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + Spacer( + Modifier.fillMaxWidth().height(2.dp).background( + if (selected) MaterialTheme.colorScheme.primary else Color.Transparent, + ), + ) + } +} + private fun formatHistoryDate(millis: Long): String = Instant.ofEpochMilli(millis) .atZone(ZoneOffset.UTC) diff --git a/app/src/main/java/dev/typetype/android/feature/library/LibraryHistoryTab.kt b/app/src/main/java/dev/typetype/android/feature/library/LibraryHistoryTab.kt index 9804c31e..5d1eb5ad 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/LibraryHistoryTab.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/LibraryHistoryTab.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -55,6 +54,8 @@ import dev.typetype.android.core.ui.components.VideoDurationBadge import dev.typetype.android.core.ui.components.AnimatedLoader import dev.typetype.android.core.ui.components.FullScreenLoader import dev.typetype.android.core.ui.components.VideoMoreActionsButton +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.library.HistoryItem import dev.typetype.android.domain.library.VideoMeta import dev.typetype.android.feature.library.components.rememberVideoMetas @@ -79,6 +80,7 @@ fun HistoryTab( onLoadMore: () -> Unit, ) { val items = pagingData.collectAsLazyPagingItems() + val serverBaseUrl = LocalServerBaseUrl.current var pendingRemoval by remember { mutableStateOf(null) } if (items.itemCount == 0 && isRefreshing) { FullScreenLoader() @@ -126,6 +128,7 @@ fun HistoryTab( HistoryRow( item = item, meta = metas[item.url], + serverBaseUrl = serverBaseUrl, onClick = { onPlayVideo(item.url) }, onOpenChannel = onOpenChannel, onPlayNext = { onPlayNext(item) }, @@ -158,6 +161,7 @@ fun HistoryTab( private fun HistoryRow( item: HistoryItem, meta: VideoMeta?, + serverBaseUrl: String?, onClick: () -> Unit, onOpenChannel: (String) -> Unit, onPlayNext: () -> Unit, @@ -193,11 +197,11 @@ private fun HistoryRow( modifier = Modifier .width(160.dp) .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(10.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -231,7 +235,7 @@ private fun HistoryRow( } } AsyncImage( - model = avatarUrl, + model = buildImageUrl(serverBaseUrl, avatarUrl), contentDescription = channelActionDescription, contentScale = ContentScale.Crop, modifier = avatarModifier, diff --git a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistsTab.kt b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistsTab.kt index f054bda5..e686a716 100644 --- a/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistsTab.kt +++ b/app/src/main/java/dev/typetype/android/feature/library/LibraryPlaylistsTab.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.MoreVert @@ -43,6 +42,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.library.Playlist @Composable @@ -55,6 +56,7 @@ fun PlaylistsTab( onRenamePlaylist: (playlistId: String, name: String) -> Unit, onDeletePlaylist: (playlistId: String) -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current var dialog by remember { mutableStateOf(null) } Column(modifier = Modifier.fillMaxSize()) { Row( @@ -86,6 +88,7 @@ fun PlaylistsTab( items(playlists, key = { it.id }) { playlist -> PlaylistListCard( playlist = playlist, + serverBaseUrl = serverBaseUrl, actionsEnabled = !isMutationInFlight, onClick = { onOpenPlaylist(playlist.id) }, onRename = { dialog = PlaylistDialog.Rename(playlist) }, @@ -132,6 +135,7 @@ fun PlaylistsTab( @Composable private fun PlaylistListCard( playlist: Playlist, + serverBaseUrl: String?, actionsEnabled: Boolean, onClick: () -> Unit, onRename: () -> Unit, @@ -153,12 +157,12 @@ private fun PlaylistListCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { playlist.videos.firstOrNull()?.thumbnailUrl?.let { cover -> AsyncImage( - model = cover, + model = buildImageUrl(serverBaseUrl, cover), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), @@ -177,7 +181,7 @@ private fun PlaylistListCard( modifier = Modifier .align(Alignment.BottomEnd) .padding(8.dp) - .clip(RoundedCornerShape(6.dp)) + .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.background.copy(alpha = 0.85f)) .padding(horizontal = 8.dp, vertical = 3.dp), ) { @@ -219,7 +223,7 @@ private fun PlaylistActionsMenu( onClick = onExpand, enabled = enabled, modifier = Modifier - .clip(RoundedCornerShape(20.dp)) + .clip(MaterialTheme.shapes.large) .background(MaterialTheme.colorScheme.background.copy(alpha = 0.85f)), ) { Icon( diff --git a/app/src/main/java/dev/typetype/android/feature/notifications/NotificationRow.kt b/app/src/main/java/dev/typetype/android/feature/notifications/NotificationRow.kt index 9cb2d0fe..8ceeaa1a 100644 --- a/app/src/main/java/dev/typetype/android/feature/notifications/NotificationRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/notifications/NotificationRow.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.notifications.NotificationItem import java.util.Date @@ -39,6 +41,7 @@ internal fun NotificationRow( onOpenVideo: () -> Unit, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current val branding = rememberVideoBranding( sourceUrl = item.video.url, title = item.video.title, @@ -52,7 +55,7 @@ internal fun NotificationRow( verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -81,7 +84,7 @@ internal fun NotificationRow( ) Row(verticalAlignment = Alignment.CenterVertically) { AsyncImage( - model = item.channelAvatarUrl, + model = buildImageUrl(serverBaseUrl, item.channelAvatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/java/dev/typetype/android/feature/notifications/NotificationsBadgeViewModel.kt b/app/src/main/java/dev/typetype/android/feature/notifications/NotificationsBadgeViewModel.kt index 1540df39..7d804ac1 100644 --- a/app/src/main/java/dev/typetype/android/feature/notifications/NotificationsBadgeViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/notifications/NotificationsBadgeViewModel.kt @@ -27,7 +27,7 @@ class NotificationsBadgeViewModel @Inject constructor( val state = repository.observeBadge().stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = NotificationBadge(), ) @@ -60,6 +60,7 @@ class NotificationsBadgeViewModel @Inject constructor( } private companion object { + const val STOP_TIMEOUT_MILLIS = 5_000L const val MIN_REFRESH_INTERVAL_NANOS = 1_000_000_000L } } 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 4e253042..c0b698f5 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 @@ -6,23 +6,27 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalContext import androidx.paging.PagingData import dev.typetype.android.domain.comments.Comment @@ -36,6 +40,8 @@ import dev.typetype.android.feature.player.components.AudioOnlyPlaybackDefault import dev.typetype.android.feature.player.components.LocalMediaController import dev.typetype.android.feature.player.components.PlayerGestureConfig import dev.typetype.android.feature.player.components.PlayerSurfaceBox +import dev.typetype.android.feature.player.components.rememberIsInPipMode +import dev.typetype.android.feature.player.components.rememberAudioOnlyPlaybackState import dev.typetype.android.feature.player.components.rememberPlaybackChapters import kotlinx.coroutines.flow.Flow import kotlin.math.roundToInt @@ -53,6 +59,7 @@ fun LoadedPlayer( playbackBrightnessPercent: Int?, autoplayCountdownSeconds: Int, audioOnlyPlaybackDefault: Boolean?, + preferredCodec: String, userSettings: UserSettings, playlists: List, playlistPickerVisible: Boolean, @@ -64,10 +71,12 @@ fun LoadedPlayer( prepareSabrPlayback: PrepareSabrPlayback, loadSubtitleCues: LoadSubtitleCues, isFullscreen: Boolean, + hostTransitionProgress: () -> Float = { 0f }, onFullscreenChange: (Boolean) -> Unit, onNavigateBack: () -> Unit, onOpenAccounts: () -> Unit, onPlayVideo: (videoUrl: String) -> Unit, + onAutoplayVideo: (videoUrl: String) -> Unit = onPlayVideo, onOpenChannel: (channelUrl: String) -> Unit = {}, isSubscribed: Boolean = false, subscriptionInFlight: Boolean = false, @@ -77,12 +86,14 @@ fun LoadedPlayer( onAction: (PlayerAction) -> Unit = {}, ) { val controller = LocalMediaController.current + val isInPip by rememberIsInPipMode() val context = LocalContext.current val codecSupport = remember(context.applicationContext) { DevicePlaybackCodecSupport(context.applicationContext) } var commentsVisible by remember { mutableStateOf(false) } var downloadPickerVisible by remember { mutableStateOf(false) } + var codecFallbackGeneration by remember(stream.id) { mutableLongStateOf(0L) } val selections = rememberPlayerPlaybackSelectionState( stream = stream, defaultQuality = userSettings.defaultQuality, @@ -91,9 +102,17 @@ fun LoadedPlayer( defaultSubtitleLanguage = userSettings.defaultSubtitleLanguage, preferOriginalLanguage = userSettings.preferOriginalLanguage, defaultPlaybackSpeed = userSettings.defaultPlaybackSpeed, + preferredCodec = preferredCodec, ) val sponsorBlockPolicy = rememberSponsorBlockPlaybackPolicy(stream, userSettings) val playbackChapters = rememberPlaybackChapters(stream.chapters, sponsorBlockPolicy) + val audioOnlyState = controller?.let { + rememberAudioOnlyPlaybackState( + controller = it, + stream = stream, + default = AudioOnlyPlaybackDefault(videoUrl, audioOnlyPlaybackDefault), + ) + } var pipSourceRect by remember(stream.id) { mutableStateOf(null) } val activity = LocalActivity.current val autoplayCountdown = rememberPlayerAutoplayCountdown( @@ -108,7 +127,7 @@ fun LoadedPlayer( onToggleQueueAutoplayPause = { onAction(PlayerAction.OnToggleQueueAutoplayPause) }, - onPlayVideo = onPlayVideo, + onPlayVideo = onAutoplayVideo, ) LaunchedEffect( @@ -116,6 +135,7 @@ fun LoadedPlayer( stream.requestScope, controller, playbackBindGeneration, + codecFallbackGeneration, selections.selectedCodec, selections.selectedQuality, selections.selectedAudioKey, @@ -148,6 +168,13 @@ fun LoadedPlayer( controller?.setPlaybackSpeed(selections.selectedSpeed) } + RuntimeCodecFallbackEffect( + player = controller, + enabled = selections.selectedCodec == RECOMMENDED_CODEC_KEY, + codecSupport = codecSupport, + onFallback = { codecFallbackGeneration += 1L }, + ) + PlayerSubtitleSelectionEffect( player = controller, selectedSubtitleKey = selections.selectedSubtitleKey, @@ -162,19 +189,30 @@ fun LoadedPlayer( onSaveProgress = { onAction(PlayerAction.OnSaveProgress(it)) }, ) + val expandedTopPadding = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + val expandedTopPaddingPx = with(LocalDensity.current) { expandedTopPadding.toPx() } + val autoplayVisible by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() < 0.01f } + } Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) .then( - if (isFullscreen) Modifier - else Modifier.windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Top), - ), + if (isFullscreen) { + Modifier + } else { + Modifier.playerTopProgressPadding( + maxTopPx = expandedTopPaddingPx, + progress = hostTransitionProgress, + ) + } ), ) { PlayerContentLayout( isFullscreen = isFullscreen, + isInPip = isInPip, + hostTransitionProgress = hostTransitionProgress, viewport = { viewportModifier -> Box( modifier = viewportModifier @@ -195,14 +233,17 @@ fun LoadedPlayer( PlayerSurfaceBox( player = controller, stream = stream, - audioOnlyDefault = AudioOnlyPlaybackDefault(videoUrl, audioOnlyPlaybackDefault), + audioOnlyState = requireNotNull(audioOnlyState), selectedCodec = selections.selectedCodec, selectedQuality = selections.selectedQuality, selectedAudioKey = selections.selectedAudioKey, selectedSubtitleKey = selections.selectedSubtitleKey, selectedSpeed = selections.selectedSpeed, codecSupport = codecSupport, - onSelectCodec = selections::selectCodec, + onSelectCodec = { + selections.selectCodec(it) + onAction(PlayerAction.OnSetPreferredCodec(it)) + }, onSelectQuality = selections::selectQuality, onSelectAudio = selections::selectAudio, onSelectSubtitle = selections::selectSubtitle, @@ -226,12 +267,13 @@ fun LoadedPlayer( captionStyles = userSettings.captionStyles, danmakuState = danmakuState, onDanmakuAction = onDanmakuAction, + hostTransitionProgress = hostTransitionProgress, modifier = Modifier.fillMaxSize(), ) } else { CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) } - autoplayCountdown?.let { + autoplayCountdown?.takeIf { autoplayVisible }?.let { AutoplayCountdownOverlay( state = it, modifier = Modifier.fillMaxSize(), @@ -251,6 +293,7 @@ fun LoadedPlayer( isSubscribed = isSubscribed, subscriptionInFlight = subscriptionInFlight, downloadInFlight = downloadInFlight, + audioOnlyState = audioOnlyState, onAction = onAction, onShowComments = { commentsVisible = true }, onShowDownloads = { downloadPickerVisible = true }, @@ -279,3 +322,23 @@ fun LoadedPlayer( onAction = onAction, ) } + +private fun Modifier.playerTopProgressPadding( + maxTopPx: Float, + progress: () -> Float, +): Modifier = layout { measurable, constraints -> + val topPx = (maxTopPx * (1f - progress().coerceIn(0f, 1f))).roundToInt() + val maxHeight = if (constraints.hasBoundedHeight) { + (constraints.maxHeight - topPx).coerceAtLeast(0) + } else { + constraints.maxHeight + } + val childConstraints = constraints.copy( + minHeight = constraints.minHeight.coerceAtMost(maxHeight), + maxHeight = maxHeight, + ) + val placeable = measurable.measure(childConstraints) + layout(placeable.width, placeable.height + topPx) { + placeable.placeRelative(0, topPx) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackCodecSupport.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackCodecSupport.kt index d2ca012c..47d92583 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackCodecSupport.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackCodecSupport.kt @@ -19,6 +19,7 @@ internal enum class DecoderSupport(val rank: Int) { internal interface PlaybackCodecSupport { fun video(source: StreamVideoSource): DecoderSupport fun audio(source: StreamAudioSource): DecoderSupport + fun rejectVideo(format: Format): Boolean = false } @OptIn(markerClass = [UnstableApi::class]) @@ -28,15 +29,13 @@ internal class DevicePlaybackCodecSupport( private val applicationContext = context.applicationContext private val videoCache = ConcurrentHashMap() private val audioCache = ConcurrentHashMap() + private val rejectedVideoFormats = ConcurrentHashMap.newKeySet() override fun video(source: StreamVideoSource): DecoderSupport { - val key = VideoCodecKey( - mimeType = source.mimeType, - codec = source.codec, - width = source.width, - height = source.height, - fps = source.fps, - ) + val key = source.videoCodecKey() + if (source.rejectedVideoCodecKey() in rejectedVideoFormats) { + return DecoderSupport.Unsupported + } videoCache[key]?.let { return it } val mediaMimeType = source.videoMediaMimeType() ?: return DecoderSupport.Unsupported val format = Format.Builder() @@ -64,6 +63,11 @@ internal class DevicePlaybackCodecSupport( return audioCache.putIfAbsent(key, support) ?: support } + override fun rejectVideo(format: Format): Boolean { + val key = format.rejectedVideoCodecKey() ?: return false + return rejectedVideoFormats.add(key) + } + private fun decoderSupport(format: Format): DecoderSupport = try { val decoders = MediaCodecUtil.getDecoderInfos( requireNotNull(format.sampleMimeType), @@ -81,13 +85,41 @@ internal class DevicePlaybackCodecSupport( } private data class VideoCodecKey( - val mimeType: String, - val codec: String?, + val codec: String, val width: Int, val height: Int, val fps: Int, ) +private data class RejectedVideoCodecKey( + val codec: String, + val width: Int, + val height: Int, +) + +private fun StreamVideoSource.videoCodecKey() = VideoCodecKey( + codec = codec.orEmpty().trim().lowercase(), + width = width, + height = height, + fps = fps, +) + +private fun StreamVideoSource.rejectedVideoCodecKey() = RejectedVideoCodecKey( + codec = codec.orEmpty().trim().lowercase(), + width = width, + height = height, +) + +private fun Format.rejectedVideoCodecKey(): RejectedVideoCodecKey? { + val codec = codecs.orEmpty().trim().lowercase().takeIf(String::isNotBlank) ?: return null + if (width <= 0 || height <= 0) return null + return RejectedVideoCodecKey( + codec = codec, + width = width, + height = height, + ) +} + private data class AudioCodecKey( val mimeType: String, val codec: String?, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackQualityKeys.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackQualityKeys.kt index 85ea61d1..d1d82738 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackQualityKeys.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackQualityKeys.kt @@ -50,7 +50,11 @@ internal fun String.effectiveQuality(automaticQualityCap: String): String = } internal fun StreamVideoSource.codecSelectionKey(): String { - val normalized = codec.orEmpty().trim().lowercase() + return codec.orEmpty().codecSelectionKey() +} + +internal fun String.codecSelectionKey(): String { + val normalized = trim().lowercase() return when { normalized.startsWith("avc1") || normalized.startsWith("avc3") -> H264_CODEC_KEY normalized.startsWith("hvc1") || normalized.startsWith("hev1") -> HEVC_CODEC_KEY diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackSourceSelector.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackSourceSelector.kt index f5abfe06..469e38a4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackSourceSelector.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackSourceSelector.kt @@ -105,8 +105,8 @@ private data class VideoCandidate( ) private val recommendedVideoComparator = compareBy { it.decoder.rank } - .thenBy { it.source.codecReliability() } .thenBy { it.source.height } + .thenBy { it.smartCodecPriority() } .thenBy { it.source.fps } .thenBy { it.source.bitrate ?: 0 } @@ -131,3 +131,19 @@ private fun StreamVideoSource.codecReliability(): Int { else -> 0 } } + +private fun VideoCandidate.smartCodecPriority(): Int = when (decoder) { + DecoderSupport.Hardware -> when (source.codecSelectionKey()) { + AV1_CODEC_KEY -> 4 + VP9_CODEC_KEY -> 3 + H264_CODEC_KEY -> 2 + else -> 1 + } + DecoderSupport.Software -> when (source.codecSelectionKey()) { + H264_CODEC_KEY -> 4 + VP9_CODEC_KEY -> 3 + AV1_CODEC_KEY -> 2 + else -> 1 + } + DecoderSupport.Unsupported -> 0 +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt index 8bb8203c..d6bda909 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerAction.kt @@ -10,6 +10,7 @@ sealed interface PlayerAction { data object OnCancelQueueAutoplay : PlayerAction data object OnToggleQueueAutoplayPause : PlayerAction data class OnSetPlaybackBrightness(val percent: Int) : PlayerAction + data class OnSetPreferredCodec(val codec: String) : PlayerAction data class OnSetAutoplay(val enabled: Boolean) : PlayerAction data class OnDownload(val selection: DownloadSelection) : PlayerAction data object OnOpenPlaylistPicker : PlayerAction diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerCommentsFlow.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerCommentsFlow.kt index 65f23330..6d55f8bd 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerCommentsFlow.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerCommentsFlow.kt @@ -22,8 +22,16 @@ internal fun playerCommentsFlow( .flatMapLatest { url -> if (url.isNullOrBlank()) flowOf(PagingData.empty()) else Pager( - config = PagingConfig(pageSize = 30, prefetchDistance = 10, enablePlaceholders = false), + config = COMMENTS_PAGING_CONFIG, pagingSourceFactory = { CommentsPagingSource(repository, url) }, ).flow } .cachedIn(scope) + +internal val COMMENTS_PAGING_CONFIG = PagingConfig( + pageSize = 30, + initialLoadSize = 60, + prefetchDistance = 10, + maxSize = 240, + enablePlaceholders = false, +) 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 059b3b05..27ca382d 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 @@ -11,9 +11,22 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.composed import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp internal const val PLAYER_SINGLE_COLUMN_LAYOUT_TAG = "player_single_column_layout" internal const val PLAYER_TWO_PANE_LAYOUT_TAG = "player_two_pane_layout" @@ -28,8 +41,9 @@ internal fun playerContentLayoutMode( widthDp: Float, heightDp: Float, isFullscreen: Boolean, + isInPip: Boolean = false, ): PlayerContentLayoutMode = when { - isFullscreen -> PlayerContentLayoutMode.Fullscreen + isFullscreen || isInPip -> PlayerContentLayoutMode.Fullscreen widthDp > heightDp && widthDp >= TWO_PANE_MIN_WIDTH_DP && heightDp >= TWO_PANE_MIN_HEIGHT_DP -> @@ -40,13 +54,39 @@ internal fun playerContentLayoutMode( @Composable internal fun PlayerContentLayout( isFullscreen: Boolean, + isInPip: Boolean = false, + hostTransitionProgress: () -> Float = { 0f }, modifier: Modifier = Modifier, viewport: @Composable (Modifier) -> Unit, details: @Composable (Modifier) -> Unit, ) { + val currentViewport by rememberUpdatedState(viewport) + val retainedViewport = remember { + movableContentOf { viewportModifier -> + currentViewport(viewportModifier) + } + } + val density = LocalDensity.current + val miniWidthPx = with(density) { MINI_VIDEO_WIDTH.toPx() } + val miniHeightPx = with(density) { MINI_VIDEO_HEIGHT.toPx() } + val miniStartPx = with(density) { MINI_VIDEO_START.toPx() } + val miniTopPx = with(density) { MINI_VIDEO_TOP.toPx() } + val detailsHidden by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() >= 0.99f } + } BoxWithConstraints(modifier = modifier.fillMaxSize()) { - when (playerContentLayoutMode(maxWidth.value, maxHeight.value, isFullscreen)) { - PlayerContentLayoutMode.Fullscreen -> viewport(Modifier.fillMaxSize()) + when (playerContentLayoutMode(maxWidth.value, maxHeight.value, isFullscreen, isInPip)) { + PlayerContentLayoutMode.Fullscreen -> retainedViewport( + Modifier + .playerViewportTransition( + progress = hostTransitionProgress, + miniWidthPx = miniWidthPx, + miniHeightPx = miniHeightPx, + miniStartPx = miniStartPx, + miniTopPx = miniTopPx, + ) + .fillMaxSize(), + ) PlayerContentLayoutMode.SingleColumn -> { Column( modifier = Modifier @@ -54,12 +94,25 @@ internal fun PlayerContentLayout( .testTag(PLAYER_SINGLE_COLUMN_LAYOUT_TAG) .verticalScroll(rememberScrollState()), ) { - viewport( + retainedViewport( + Modifier + .playerViewportTransition( + progress = hostTransitionProgress, + miniWidthPx = miniWidthPx, + miniHeightPx = miniHeightPx, + miniStartPx = miniStartPx, + miniTopPx = miniTopPx, + ) + .fillMaxWidth() + .aspectRatio(VIDEO_ASPECT_RATIO) + .testTag(PLAYER_VIEWPORT_TAG), + ) + details( Modifier .fillMaxWidth() - .aspectRatio(VIDEO_ASPECT_RATIO), + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) - details(Modifier.fillMaxWidth()) } } PlayerContentLayoutMode.TwoPane -> { @@ -72,19 +125,29 @@ internal fun PlayerContentLayout( modifier = Modifier .weight(PLAYER_PANE_WEIGHT) .fillMaxHeight(), - contentAlignment = Alignment.TopCenter, + contentAlignment = Alignment.TopStart, ) { - viewport( + retainedViewport( Modifier + .playerViewportTransition( + progress = hostTransitionProgress, + miniWidthPx = miniWidthPx, + miniHeightPx = miniHeightPx, + miniStartPx = miniStartPx, + miniTopPx = miniTopPx, + ) .fillMaxWidth() - .aspectRatio(VIDEO_ASPECT_RATIO), + .aspectRatio(VIDEO_ASPECT_RATIO) + .testTag(PLAYER_VIEWPORT_TAG), ) } details( Modifier .weight(DETAILS_PANE_WEIGHT) .fillMaxHeight() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) + .playerDetailsTransition(hostTransitionProgress) + .then(if (detailsHidden) Modifier.clearAndSetSemantics { } else Modifier), ) } } @@ -92,8 +155,37 @@ internal fun PlayerContentLayout( } } +private fun Modifier.playerViewportTransition( + progress: () -> Float, + miniWidthPx: Float, + miniHeightPx: Float, + miniStartPx: Float, + miniTopPx: Float, +): Modifier = graphicsLayer { + val fraction = progress().coerceIn(0f, 1f) + val targetScaleX = miniWidthPx / size.width.coerceAtLeast(1f) + val targetScaleY = miniHeightPx / size.height.coerceAtLeast(1f) + scaleX = 1f + (targetScaleX - 1f) * fraction + scaleY = 1f + (targetScaleY - 1f) * fraction + translationX = miniStartPx * fraction + translationY = miniTopPx * fraction + transformOrigin = TransformOrigin(0f, 0f) + this.shape = RoundedCornerShape(MINI_VIDEO_CORNER * fraction) + clip = fraction > 0f +} + +private fun Modifier.playerDetailsTransition(progress: () -> Float): Modifier = graphicsLayer { + alpha = 1f - progress().coerceIn(0f, 1f) +} + private const val VIDEO_ASPECT_RATIO = 16f / 9f private const val TWO_PANE_MIN_WIDTH_DP = 840f private const val TWO_PANE_MIN_HEIGHT_DP = 480f private const val PLAYER_PANE_WEIGHT = 1.45f private const val DETAILS_PANE_WEIGHT = 1f +private val MINI_VIDEO_WIDTH = 80.dp +private val MINI_VIDEO_HEIGHT = 45.dp +private val MINI_VIDEO_START = 8.dp +private val MINI_VIDEO_TOP = 9.dp +private val MINI_VIDEO_CORNER = 6.dp +internal const val PLAYER_VIEWPORT_TAG = "player_viewport" 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 99a7f448..184796b6 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 @@ -16,6 +16,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.feature.menu.rememberVideoMenuScope import dev.typetype.android.feature.player.components.DescriptionSection +import dev.typetype.android.feature.player.components.AudioOnlyPlaybackState import dev.typetype.android.feature.player.components.RelatedStreamsSection import dev.typetype.android.feature.player.components.UploaderCard import dev.typetype.android.feature.player.queue.PlaybackQueueControls @@ -33,6 +34,7 @@ internal fun PlayerDetails( isSubscribed: Boolean, subscriptionInFlight: Boolean, downloadInFlight: Boolean, + audioOnlyState: AudioOnlyPlaybackState?, onAction: (PlayerAction) -> Unit, onShowComments: () -> Unit, onShowDownloads: () -> Unit, @@ -72,6 +74,12 @@ internal fun PlayerDetails( onShowComments = onShowComments.takeUnless { userSettings.hideComments }, onDownload = onShowDownloads, downloadInFlight = downloadInFlight, + audioOnlyEnabled = audioOnlyState?.active == true, + audioOnlyAvailable = audioOnlyState?.available == true, + audioOnlyChanging = audioOnlyState?.changing == true, + onToggleAudioOnly = { + audioOnlyState?.setEnabled(audioOnlyState.active.not()) + }, ) UploaderCard( name = stream.uploaderName, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerFullscreenEffect.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerFullscreenEffect.kt index 3b62518a..880ea86b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerFullscreenEffect.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerFullscreenEffect.kt @@ -12,8 +12,9 @@ internal fun PlayerFullscreenEffect( activity: Activity?, isFullscreen: Boolean, locksLandscape: Boolean, + restoresPortraitOnExit: Boolean = false, ) { - LaunchedEffect(isFullscreen, locksLandscape) { + LaunchedEffect(isFullscreen, locksLandscape, restoresPortraitOnExit) { val window = activity?.window ?: return@LaunchedEffect if (isFullscreen) { activity.requestedOrientation = if (locksLandscape) { @@ -23,7 +24,11 @@ internal fun PlayerFullscreenEffect( } WindowHelper.toggleFullscreen(window, isFullscreen = true) } else { - activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + activity.requestedOrientation = if (restoresPortraitOnExit) { + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } WindowHelper.toggleFullscreen(window, isFullscreen = false) } } 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 a7e5ffba..062573e6 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 @@ -5,12 +5,15 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.GraphicEq import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.WatchLater import androidx.compose.material.icons.outlined.ChatBubbleOutline @@ -40,13 +43,17 @@ fun PlayerInteractionRow( onShowComments: (() -> Unit)?, onDownload: () -> Unit, downloadInFlight: Boolean = false, + audioOnlyEnabled: Boolean = false, + audioOnlyAvailable: Boolean = false, + audioOnlyChanging: Boolean = false, + onToggleAudioOnly: () -> Unit = {}, ) { val context = LocalContext.current val shareChooserTitle = stringResource(R.string.video_menu_share_chooser) val serverBaseUrl = LocalServerBaseUrl.current Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { PlayerActionButton( icon = if (isFavorited) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, @@ -78,6 +85,15 @@ fun PlayerInteractionRow( onClick = it, ) } + if (audioOnlyAvailable) { + PlayerActionButton( + icon = Icons.Filled.GraphicEq, + contentDescription = stringResource(R.string.player_audio_only), + selected = audioOnlyEnabled, + enabled = !audioOnlyChanging, + onClick = onToggleAudioOnly, + ) + } PlayerActionButton( icon = Icons.Filled.Download, contentDescription = stringResource(R.string.player_download), diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionState.kt index f5119a6e..90d3ab9b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionState.kt @@ -16,9 +16,10 @@ internal class PlayerPlaybackSelectionState( subtitlesEnabled: Boolean, defaultSubtitleLanguage: String, preferOriginalLanguage: Boolean, + preferredCodec: String = RECOMMENDED_CODEC_KEY, defaultPlaybackSpeed: Double = 1.0, ) { - var selectedCodec by mutableStateOf(RECOMMENDED_CODEC_KEY) + var selectedCodec by mutableStateOf(preferredCodec) private set var selectedQuality by mutableStateOf(stream.initialQuality()) private set @@ -97,6 +98,7 @@ internal fun rememberPlayerPlaybackSelectionState( defaultSubtitleLanguage: String, preferOriginalLanguage: Boolean, defaultPlaybackSpeed: Double, + preferredCodec: String = RECOMMENDED_CODEC_KEY, ): PlayerPlaybackSelectionState { val state = remember( stream.id, @@ -106,6 +108,7 @@ internal fun rememberPlayerPlaybackSelectionState( defaultSubtitleLanguage, preferOriginalLanguage, defaultPlaybackSpeed, + preferredCodec, ) { PlayerPlaybackSelectionState( stream = stream, @@ -114,6 +117,7 @@ internal fun rememberPlayerPlaybackSelectionState( defaultSubtitleLanguage = defaultSubtitleLanguage, preferOriginalLanguage = preferOriginalLanguage, defaultPlaybackSpeed = defaultPlaybackSpeed, + preferredCodec = preferredCodec, ) } LaunchedEffect(stream.subtitles, subtitlesEnabled, defaultSubtitleLanguage) { diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt index b5f57cec..7ba9be2c 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceCoordinator.kt @@ -23,13 +23,16 @@ internal class PlayerPreferenceCoordinator( PlayerPreferenceState( gestureConfig = PlayerGestureConfig( doubleTapSeekEnabled = preferences.playerDoubleTapSeekEnabled, + doubleTapSeekSeconds = preferences.playerDoubleTapSeekSeconds, swipeSeekEnabled = preferences.playerSwipeSeekEnabled, swipeBrightnessVolumeEnabled = preferences.playerSwipeBrightnessVolumeEnabled, longPressSpeedEnabled = preferences.playerLongPressSpeedEnabled, + accessibleControlsEnabled = preferences.playerAccessibleControlsEnabled, ), brightnessPercent = preferences.playerPlaybackBrightnessPercent, autoplayCountdownSeconds = preferences.playerAutoplayCountdownSeconds, audioOnlyPlaybackDefault = preferences.playerAudioOnlyPlayback, + preferredCodec = preferences.playerPreferredCodec, userSettings = userSettings, ) } @@ -60,6 +63,10 @@ internal class PlayerPreferenceCoordinator( server.update { it.copy(autoplay = enabled) }.onFailure { onFailure() } } } + + fun updatePreferredCodec(codec: String) { + scope.launch { local.setPlayerPreferredCodec(codec) } + } } internal data class PlayerPreferenceState( @@ -67,6 +74,7 @@ internal data class PlayerPreferenceState( val brightnessPercent: Int?, val autoplayCountdownSeconds: Int, val audioOnlyPlaybackDefault: Boolean, + val preferredCodec: String, val userSettings: UserSettings, ) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt index b3e50c10..b8f7a904 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt @@ -28,10 +28,12 @@ private const val PLAYLIST_NAME_PLACEHOLDER = "__PLAYLIST_NAME__" @Composable fun PlayerRoute( isFullscreen: Boolean, + hostTransitionProgress: () -> Float = { 0f }, onFullscreenChange: (Boolean) -> Unit, onNavigateBack: () -> Unit, onOpenAccounts: () -> Unit, onPlayVideo: (videoUrl: String) -> Unit, + onAutoplayVideo: (videoUrl: String) -> Unit = onPlayVideo, onOpenChannel: (channelUrl: String) -> Unit = {}, modifier: Modifier = Modifier, viewModel: PlayerViewModel = hiltViewModel(), @@ -53,10 +55,12 @@ fun PlayerRoute( prepareSabrPlayback = viewModel.sabrPlayback::prepare, loadSubtitleCues = viewModel.subtitleCueLoader::load, isFullscreen = isFullscreen, + hostTransitionProgress = hostTransitionProgress, onFullscreenChange = onFullscreenChange, onNavigateBack = onNavigateBack, onOpenAccounts = onOpenAccounts, onPlayVideo = onPlayVideo, + onAutoplayVideo = onAutoplayVideo, onOpenChannel = onOpenChannel, modifier = modifier, isSubscribed = channelState.isSubscribed(channelUrl), @@ -79,10 +83,12 @@ fun PlayerScreen( prepareSabrPlayback: PrepareSabrPlayback = { _, _, _ -> null }, loadSubtitleCues: LoadSubtitleCues = { Result.success(emptyList()) }, isFullscreen: Boolean = false, + hostTransitionProgress: () -> Float = { 0f }, onFullscreenChange: (Boolean) -> Unit = {}, onNavigateBack: () -> Unit, onOpenAccounts: () -> Unit, onPlayVideo: (videoUrl: String) -> Unit, + onAutoplayVideo: (videoUrl: String) -> Unit = onPlayVideo, onOpenChannel: (channelUrl: String) -> Unit = {}, isSubscribed: Boolean = false, subscriptionInFlight: Boolean = false, @@ -165,6 +171,7 @@ fun PlayerScreen( playbackBrightnessPercent = state.playbackBrightnessPercent, autoplayCountdownSeconds = state.autoplayCountdownSeconds, audioOnlyPlaybackDefault = state.audioOnlyPlaybackDefault, + preferredCodec = state.preferredCodec, userSettings = state.userSettings, playlists = state.playlists, playlistPickerVisible = state.playlistPickerVisible, @@ -176,10 +183,12 @@ fun PlayerScreen( prepareSabrPlayback = prepareSabrPlayback, loadSubtitleCues = loadSubtitleCues, isFullscreen = isFullscreen, + hostTransitionProgress = hostTransitionProgress, onFullscreenChange = onFullscreenChange, onNavigateBack = onNavigateBack, onOpenAccounts = onOpenAccounts, onPlayVideo = onPlayVideo, + onAutoplayVideo = onAutoplayVideo, onOpenChannel = onOpenChannel, isSubscribed = isSubscribed, subscriptionInFlight = subscriptionInFlight, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt index f129c53b..3a8225e8 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerState.kt @@ -21,6 +21,7 @@ data class PlayerState( val playbackBrightnessPercent: Int? = null, val autoplayCountdownSeconds: Int = 10, val audioOnlyPlaybackDefault: Boolean? = null, + val preferredCodec: String = RECOMMENDED_CODEC_KEY, val userSettings: UserSettings = UserSettings(), val playlistPickerVisible: Boolean = false, val playlists: List = emptyList(), diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt index b6d5e45a..0d2dd90d 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt @@ -139,6 +139,7 @@ class PlayerViewModel @Inject constructor( playbackBrightnessPercent = prefs.brightnessPercent, autoplayCountdownSeconds = prefs.autoplayCountdownSeconds, audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault, + preferredCodec = prefs.preferredCodec, userSettings = prefs.userSettings, ) } @@ -158,6 +159,10 @@ class PlayerViewModel @Inject constructor( is PlayerAction.OnSetPlaybackBrightness -> playerPreferences.updateBrightness(action.percent) { _state.update { state -> state.copy(playbackBrightnessPercent = it) } } + is PlayerAction.OnSetPreferredCodec -> { + _state.update { it.copy(preferredCodec = action.codec) } + playerPreferences.updatePreferredCodec(action.codec) + } is PlayerAction.OnSetAutoplay -> updateAutoplay(action.enabled) is PlayerAction.OnDownload -> downloadCurrentVideo(action.selection) PlayerAction.OnOpenPlaylistPicker -> diff --git a/app/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt b/app/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt new file mode 100644 index 00000000..39d2e857 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/RuntimeCodecFallback.kt @@ -0,0 +1,60 @@ +package dev.typetype.android.feature.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Tracks + +@Composable +internal fun RuntimeCodecFallbackEffect( + player: Player?, + enabled: Boolean, + codecSupport: PlaybackCodecSupport, + onFallback: () -> Unit, +) { + val fallback = rememberUpdatedState(onFallback) + DisposableEffect(player, enabled, codecSupport) { + if (player == null || !enabled) return@DisposableEffect onDispose {} + var activeFormat = player.currentTracks.selectedVideoFormat() + val listener = object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + activeFormat = tracks.selectedVideoFormat() ?: activeFormat + } + + override fun onPlayerError(error: PlaybackException) { + val messages = generateSequence(error) { it.cause } + .mapNotNull(Throwable::message) + .toList() + if (!isCodecCapabilityFailure(error.errorCode, messages)) return + if (activeFormat?.let(codecSupport::rejectVideo) == true) fallback.value() + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } +} + +internal fun isCodecCapabilityFailure(errorCode: Int, messages: List): Boolean { + val mentionsSurface = messages.any { it.contains("surface", ignoreCase = true) } + return when (errorCode) { + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + -> true + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED -> !mentionsSurface + else -> false + } +} + +private fun Tracks.selectedVideoFormat(): Format? = groups + .asSequence() + .filter { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + .flatMap { group -> + (0 until group.length).asSequence() + .filter(group::isTrackSelected) + .map(group::getTrackFormat) + } + .maxByOrNull { it.height } diff --git a/app/src/main/java/dev/typetype/android/feature/player/ShortsPlaybackProgress.kt b/app/src/main/java/dev/typetype/android/feature/player/ShortsPlaybackProgress.kt index b2444d03..2a1ce20b 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/ShortsPlaybackProgress.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/ShortsPlaybackProgress.kt @@ -9,11 +9,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval +import dev.typetype.android.R import dev.typetype.android.feature.player.components.TimelineTrack +import dev.typetype.android.feature.player.components.formatPlayerTime @OptIn(markerClass = [UnstableApi::class]) @Composable @@ -25,8 +28,9 @@ internal fun ShortsPlaybackProgress( val progress = rememberProgressStateWithTickInterval(player, SHORTS_PROGRESS_TICK_MS) val durationMs = progress.durationMs.takeIf { it > 0L } ?: fallbackDurationMs.coerceAtLeast(0L) var scrubPositionMs by remember { mutableStateOf(null) } + val positionMs = scrubPositionMs ?: progress.currentPositionMs TimelineTrack( - positionMs = scrubPositionMs ?: progress.currentPositionMs, + positionMs = positionMs, durationMs = durationMs, segments = emptyList(), compact = false, @@ -36,6 +40,12 @@ internal fun ShortsPlaybackProgress( scrubPositionMs = null }, onScrubCancelled = { scrubPositionMs = null }, + accessibilityLabel = stringResource(R.string.player_timeline), + accessibilityStateDescription = stringResource( + R.string.player_timeline_position, + formatPlayerTime(positionMs), + formatPlayerTime(durationMs), + ), modifier = modifier.fillMaxWidth().height(36.dp), ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/ShortsPlayer.kt b/app/src/main/java/dev/typetype/android/feature/player/ShortsPlayer.kt index 10da958f..0d19fca0 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/ShortsPlayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/ShortsPlayer.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue @@ -73,6 +74,9 @@ fun ShortsPlayerRoute( loadSubtitleCues = viewModel.subtitleCueLoader::load, onAdvance = onAdvance, onRetry = { viewModel.onAction(PlayerAction.OnRetry) }, + onSelectCodec = { + viewModel.onAction(PlayerAction.OnSetPreferredCodec(it)) + }, onSaveProgress = { viewModel.onAction(PlayerAction.OnSaveProgress(it)) }, @@ -88,6 +92,7 @@ private fun ShortsPlayerSurface( loadSubtitleCues: LoadSubtitleCues, onAdvance: () -> Unit, onRetry: () -> Unit, + onSelectCodec: (String) -> Unit, onSaveProgress: (Long) -> Unit, ) { val stream = requireNotNull(state.stream) @@ -105,10 +110,12 @@ private fun ShortsPlayerSurface( defaultSubtitleLanguage = state.userSettings.defaultSubtitleLanguage, preferOriginalLanguage = state.userSettings.preferOriginalLanguage, defaultPlaybackSpeed = state.userSettings.defaultPlaybackSpeed, + preferredCodec = state.preferredCodec, ) val sponsorBlockPolicy = rememberSponsorBlockPlaybackPolicy(stream, state.userSettings) var playbackOptionsVisible by remember(stream.id) { mutableStateOf(false) } var resizeMode by remember(stream.id) { mutableStateOf(ResizeMode.Crop) } + var codecFallbackGeneration by remember(stream.id) { mutableLongStateOf(0L) } val playbackStatus = controller?.let { rememberPlayerPlaybackStatus( it, @@ -126,6 +133,7 @@ private fun ShortsPlayerSurface( stream.id, stream.requestScope, state.playbackBindGeneration, + codecFallbackGeneration, selections.selectedCodec, selections.selectedQuality, selections.selectedAudioKey, @@ -153,6 +161,12 @@ private fun ShortsPlayerSurface( } PlayerSubtitleSelectionEffect(controller, selections.selectedSubtitleKey) + RuntimeCodecFallbackEffect( + player = controller, + enabled = selections.selectedCodec == RECOMMENDED_CODEC_KEY, + codecSupport = codecSupport, + onFallback = { codecFallbackGeneration += 1L }, + ) PlayerProgressEffects( controller = controller, activity = activity, @@ -267,7 +281,10 @@ private fun ShortsPlayerSurface( audioOnlyEnabled = false, audioOnlyChanging = false, showAudioOnly = false, - onSelectCodec = selections::selectCodec, + onSelectCodec = { + selections.selectCodec(it) + onSelectCodec(it) + }, onSelectQuality = selections::selectQuality, onSelectAudio = selections::selectAudio, onSelectSubtitle = selections::selectSubtitle, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPlaybackState.kt b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPlaybackState.kt index 7bff296f..4a6d7089 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPlaybackState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPlaybackState.kt @@ -43,6 +43,7 @@ internal class AudioOnlyPlaybackState( fun setEnabled(enabled: Boolean, defaultRequest: Boolean = false) { if (!available || changing || active == enabled) return changing = true + active = enabled failure = null val future = controller.sendCustomCommand( PlaybackAudioOnlyCommand.command, @@ -56,6 +57,7 @@ internal class AudioOnlyPlaybackState( if (result?.resultCode == SessionResult.RESULT_SUCCESS) { synchronize() } else { + active = !enabled failure = when (result?.resultCode) { SessionError.ERROR_IO -> AudioOnlyPlaybackFailure.Network else -> AudioOnlyPlaybackFailure.Unavailable diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPoster.kt b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPoster.kt index db1c735d..649403fc 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPoster.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyPoster.kt @@ -1,152 +1,211 @@ package dev.typetype.android.feature.player.components +import android.graphics.Bitmap +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Headphones -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil3.BitmapImage import coil3.compose.AsyncImage -import dev.typetype.android.R +import coil3.request.ImageRequest +import coil3.request.allowHardware +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl @Composable internal fun AudioOnlyPoster( thumbnailUrl: String, title: String, + isPlaying: Boolean, modifier: Modifier = Modifier, ) { + var palette by remember(thumbnailUrl) { mutableStateOf(AudioOnlyPalette.Default) } Box( - modifier = modifier.background(Color.Black), + modifier = modifier.background( + Brush.verticalGradient( + listOf( + palette.primary.copy(alpha = 0.72f), + palette.secondary.copy(alpha = 0.38f), + Color.Black, + ), + ), + ), contentAlignment = Alignment.Center, ) { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().alpha(0.24f), - ) - Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.42f))) BoxWithConstraints(contentAlignment = Alignment.Center) { if (maxHeight < 280.dp) { - AudioOnlyCompactContent(thumbnailUrl = thumbnailUrl, title = title) + AudioOnlyCompactContent( + thumbnailUrl, + title, + isPlaying, + palette, + onPalette = { palette = it }, + ) } else { - AudioOnlyExpandedContent(thumbnailUrl = thumbnailUrl, title = title) + AudioOnlyExpandedContent( + thumbnailUrl, + title, + isPlaying, + palette, + onPalette = { palette = it }, + ) } } } } @Composable -private fun AudioOnlyExpandedContent(thumbnailUrl: String, title: String) { +private fun AudioOnlyExpandedContent( + thumbnailUrl: String, + title: String, + isPlaying: Boolean, + palette: AudioOnlyPalette, + onPalette: (AudioOnlyPalette) -> Unit, +) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(horizontal = 32.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), ) { - AudioOnlyArtwork(thumbnailUrl = thumbnailUrl, size = 112.dp) - AudioOnlyDetails(title = title, centered = true) + AudioOnlyArtwork(thumbnailUrl, 112.dp, onPalette) + AudioOnlyTitle(title, centered = true) + AudioOnlyWaveform(isPlaying, palette, Modifier.fillMaxWidth().height(64.dp)) } } @Composable -private fun AudioOnlyCompactContent(thumbnailUrl: String, title: String) { +private fun AudioOnlyCompactContent( + thumbnailUrl: String, + title: String, + isPlaying: Boolean, + palette: AudioOnlyPalette, + onPalette: (AudioOnlyPalette) -> Unit, +) { Row( horizontalArrangement = Arrangement.spacedBy(18.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 28.dp, vertical = 18.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 18.dp), ) { - AudioOnlyArtwork(thumbnailUrl = thumbnailUrl, size = 88.dp) + AudioOnlyArtwork(thumbnailUrl, 88.dp, onPalette) Column(modifier = Modifier.weight(1f)) { - AudioOnlyDetails(title = title, centered = false) + AudioOnlyTitle(title, centered = false) + AudioOnlyWaveform(isPlaying, palette, Modifier.fillMaxWidth().height(42.dp)) } } } @Composable -private fun AudioOnlyArtwork(thumbnailUrl: String, size: androidx.compose.ui.unit.Dp) { +private fun AudioOnlyArtwork( + thumbnailUrl: String, + size: Dp, + onPalette: (AudioOnlyPalette) -> Unit, +) { + val context = LocalContext.current + val serverBaseUrl = LocalServerBaseUrl.current + val request = remember(context, serverBaseUrl, thumbnailUrl) { + ImageRequest.Builder(context) + .data(buildImageUrl(serverBaseUrl, thumbnailUrl)) + .size(224) + .allowHardware(false) + .build() + } AsyncImage( - model = thumbnailUrl, + model = request, contentDescription = null, contentScale = ContentScale.Crop, + onSuccess = { success -> + (success.result.image as? BitmapImage)?.bitmap?.let { + onPalette(it.audioOnlyPalette()) + } + }, modifier = Modifier.size(size).clip(RoundedCornerShape(18.dp)), ) } @Composable -private fun AudioOnlyDetails(title: String, centered: Boolean) { - Icon( - imageVector = Icons.Filled.Headphones, - contentDescription = stringResource(R.string.player_audio_only), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(26.dp), - ) - Text( +private fun AudioOnlyTitle(title: String, centered: Boolean) { + androidx.compose.material3.Text( text = title, color = Color.White, - style = MaterialTheme.typography.titleMedium, + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, textAlign = if (centered) TextAlign.Center else TextAlign.Start, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 4.dp), ) - AudioOnlyBars() } -@Composable -private fun AudioOnlyBars() { - val transition = rememberInfiniteTransition(label = "audioWave") - val heights = listOf(520, 690, 430, 760, 590).mapIndexed { index, duration -> - val height by transition.animateFloat( - initialValue = 0.28f + index * 0.04f, - targetValue = 1f - index * 0.05f, - animationSpec = infiniteRepeatable( - animation = tween(durationMillis = duration), - repeatMode = RepeatMode.Reverse, - ), - label = "audioWave$index", - ) - height +internal data class AudioOnlyPalette( + val primary: Color, + val secondary: Color, + val highlight: Color, +) { + companion object { + val Default = AudioOnlyPalette(Color(0xFF1D4ED8), Color(0xFF111827), Color(0xFF60A5FA)) } - Row( - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.size(width = 54.dp, height = 22.dp), - ) { - heights.forEach { height -> - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight(height) - .clip(RoundedCornerShape(4.dp)) - .background(MaterialTheme.colorScheme.primary), +} + +private fun Bitmap.audioOnlyPalette(): AudioOnlyPalette { + var red = 0L + var green = 0L + var blue = 0L + var count = 0 + repeat(SAMPLE_GRID) { row -> + repeat(SAMPLE_GRID) { column -> + val pixel = getPixel( + ((column + 0.5f) * width / SAMPLE_GRID).toInt().coerceIn(0, width - 1), + ((row + 0.5f) * height / SAMPLE_GRID).toInt().coerceIn(0, height - 1), ) + red += android.graphics.Color.red(pixel) + green += android.graphics.Color.green(pixel) + blue += android.graphics.Color.blue(pixel) + count += 1 } } + val hsv = FloatArray(3) + android.graphics.Color.RGBToHSV( + (red / count).toInt(), + (green / count).toInt(), + (blue / count).toInt(), + hsv, + ) + hsv[1] = hsv[1].coerceAtLeast(0.58f) + hsv[2] = hsv[2].coerceIn(0.42f, 0.72f) + val primary = Color(android.graphics.Color.HSVToColor(hsv)) + val shifted = hsv.copyOf().also { it[0] = (it[0] + 32f) % 360f } + val highlighted = hsv.copyOf().also { it[2] = 1f } + return AudioOnlyPalette( + primary = primary, + secondary = Color(android.graphics.Color.HSVToColor(shifted)), + highlight = Color(android.graphics.Color.HSVToColor(highlighted)), + ) } + +private const val SAMPLE_GRID = 6 diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyWaveform.kt b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyWaveform.kt new file mode 100644 index 00000000..71c8f4ef --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/AudioOnlyWaveform.kt @@ -0,0 +1,99 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import kotlin.math.PI +import kotlin.math.sin +import kotlin.random.Random + +private const val BAR_COUNT = 112 +private const val WAVE_SEED = 0x525459 + +private class WaveMotion(count: Int, seed: Int) { + private val random = Random(seed) + val levels = FloatArray(count) { random.nextFloat() } + val targets = FloatArray(count) { random.nextFloat() } + val phases = FloatArray(count) { random.nextFloat() * 2f * PI.toFloat() } + val speeds = FloatArray(count) { 0.018f + random.nextFloat() * 0.055f } + val intervals = IntArray(count) { 4 + random.nextInt(10) } + val offsets = IntArray(count) { random.nextInt(intervals[it]) } + + fun nextTarget(index: Int): Float { + val target = random.nextFloat() + targets[index] = target + return target + } +} + +@Composable +internal fun AudioOnlyWaveform( + isPlaying: Boolean, + palette: AudioOnlyPalette, + modifier: Modifier, +) { + val frame = remember { mutableIntStateOf(0) } + val motion = remember { WaveMotion(BAR_COUNT, WAVE_SEED) } + + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { } + frame.intValue += 1 + } + } + + Canvas(modifier) { + val frameCount = frame.intValue + val slotWidth = size.width / motion.levels.size + val barWidth = slotWidth * 0.71f + val gap = slotWidth - barWidth + val signal = if (isPlaying) 0.34f else 0f + val brush = Brush.verticalGradient( + colors = listOf( + palette.highlight.copy(alpha = 0.25f), + palette.primary.copy(alpha = 0.66f), + palette.secondary.copy(alpha = 0.16f), + ), + ) + + repeat(motion.levels.size) { index -> + val interval = motion.intervals[index].coerceAtLeast(1) + if ((frameCount + motion.offsets[index]) % interval == 0) { + motion.nextTarget(index) + } + motion.levels[index] += ( + motion.targets[index] - motion.levels[index] + ) * 0.11f + val phase = frameCount * motion.speeds[index] + motion.phases[index] + val pulse = 0.5f + sin(phase) * 0.5f + val movement = signal * ( + 0.08f + motion.levels[index] * 0.52f + pulse * 0.26f + ) + val requested = if (isPlaying) 0.035f + movement else 0.025f + motion.levels[index] += ( + requested.coerceIn(0f, 1f) - motion.levels[index] + ) * if (isPlaying) 0.22f else 0.08f + + val level = motion.levels[index] + val edgeFade = 0.65f + sin( + index.toFloat() / motion.levels.size * PI.toFloat(), + ) * 0.35f + val barHeight = maxOf( + size.height * 0.025f, + level * edgeFade * size.height * 0.58f, + ) + drawRoundRect( + brush = brush, + topLeft = Offset(index * slotWidth + gap / 2f, (size.height - barHeight) / 2f), + size = Size(barWidth, barHeight), + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/AutoplayCountdownOverlay.kt b/app/src/main/java/dev/typetype/android/feature/player/components/AutoplayCountdownOverlay.kt index 55c03ff7..3f0fae08 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/AutoplayCountdownOverlay.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/AutoplayCountdownOverlay.kt @@ -31,6 +31,8 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.feature.player.AutoplayCountdownState @Composable @@ -38,6 +40,7 @@ internal fun AutoplayCountdownOverlay( state: AutoplayCountdownState, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current val branding = rememberVideoBranding( sourceUrl = state.target.videoUrl, title = state.target.title, @@ -46,7 +49,7 @@ internal fun AutoplayCountdownOverlay( ) Box(modifier = modifier.background(Color.Black)) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/ChaptersSheet.kt b/app/src/main/java/dev/typetype/android/feature/player/components/ChaptersSheet.kt index 12ebc079..e3cd577d 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/ChaptersSheet.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/ChaptersSheet.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.stream.Chapter @OptIn(ExperimentalMaterial3Api::class) @@ -39,6 +41,7 @@ fun ChaptersSheet( onChapterClick: (Chapter) -> Unit, onDismiss: () -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) ModalBottomSheet( onDismissRequest = onDismiss, @@ -56,10 +59,11 @@ fun ChaptersSheet( modifier = Modifier.padding(vertical = 8.dp), ) } - items(chapters, key = { it.startMs }) { chapter -> + items(chapters, key = { it.startMs }, contentType = { "chapter" }) { chapter -> val isCurrent = isChapterActive(chapter, chapters, currentPositionMs) ChapterRow( chapter = chapter, + serverBaseUrl = serverBaseUrl, isCurrent = isCurrent, onClick = { onChapterClick(chapter) }, ) @@ -69,7 +73,12 @@ fun ChaptersSheet( } @Composable -private fun ChapterRow(chapter: Chapter, isCurrent: Boolean, onClick: () -> Unit) { +private fun ChapterRow( + chapter: Chapter, + serverBaseUrl: String?, + isCurrent: Boolean, + onClick: () -> Unit, +) { Row( modifier = Modifier .fillMaxWidth() @@ -86,7 +95,7 @@ private fun ChapterRow(chapter: Chapter, isCurrent: Boolean, onClick: () -> Unit .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = chapter.previewUrl, + model = buildImageUrl(serverBaseUrl, chapter.previewUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt index 66c1b8e0..d9f4ef58 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt @@ -33,6 +33,8 @@ import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.comments.Comment @Composable @@ -42,9 +44,10 @@ internal fun CommentBody( onUrlClick: (String) -> Unit, onTimestampClick: (Long) -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current Row(modifier = Modifier.fillMaxWidth()) { AsyncImage( - model = comment.authorAvatarUrl, + model = buildImageUrl(serverBaseUrl, comment.authorAvatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/DeArrowAudioOnlyPoster.kt b/app/src/main/java/dev/typetype/android/feature/player/components/DeArrowAudioOnlyPoster.kt index 98281c09..b549d064 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/DeArrowAudioOnlyPoster.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/DeArrowAudioOnlyPoster.kt @@ -8,6 +8,7 @@ import dev.typetype.android.domain.stream.Stream @Composable internal fun DeArrowAudioOnlyPoster( stream: Stream, + isPlaying: Boolean, modifier: Modifier = Modifier, ) { val branding = rememberVideoBranding( @@ -19,6 +20,7 @@ internal fun DeArrowAudioOnlyPoster( AudioOnlyPoster( thumbnailUrl = branding.thumbnailUrl, title = branding.title, + isPlaying = isPlaying, modifier = modifier, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/MiniPlayerBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/MiniPlayerBar.kt index e36b6a9d..f9a0b105 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/MiniPlayerBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/MiniPlayerBar.kt @@ -29,17 +29,18 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberPlayPauseButtonState -import coil3.compose.AsyncImage import dev.typetype.android.R @OptIn(markerClass = [UnstableApi::class]) @@ -48,7 +49,6 @@ fun MiniPlayerBar( player: Player, title: String, subtitle: String, - artworkUri: String?, onExpand: () -> Unit, onSendToBackground: () -> Unit, onClose: () -> Unit, @@ -56,13 +56,14 @@ fun MiniPlayerBar( sleepTimerLabel: String? = null, ) { val playPauseState = rememberPlayPauseButtonState(player) + val backgroundActionLabel = stringResource(R.string.mini_player_send_to_background) + val expandActionLabel = stringResource(R.string.mini_player_expand) val density = LocalDensity.current val swipeThresholdPx = with(density) { MINI_PLAYER_SWIPE_THRESHOLD.toPx() } Row( modifier = modifier .fillMaxWidth() .height(64.dp) - .background(MaterialTheme.colorScheme.surface) .pointerInput(onExpand, onSendToBackground) { awaitEachGesture { val down = awaitFirstDown( @@ -86,7 +87,15 @@ fun MiniPlayerBar( } } } - .clickable(onClick = onExpand), + .semantics { + customActions = listOf( + CustomAccessibilityAction(backgroundActionLabel) { + onSendToBackground() + true + }, + ) + } + .clickable(onClickLabel = expandActionLabel, onClick = onExpand), verticalAlignment = Alignment.CenterVertically, ) { Box( @@ -95,17 +104,8 @@ fun MiniPlayerBar( .width(80.dp) .aspectRatio(16f / 9f) .clip(RoundedCornerShape(6.dp)) - .background(Color.Black), - ) { - if (artworkUri != null) { - AsyncImage( - model = artworkUri, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth(), - ) - } - } + .background(Color.Transparent), + ) Spacer(Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormat.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormat.kt new file mode 100644 index 00000000..5abde1f9 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackActiveVideoFormat.kt @@ -0,0 +1,103 @@ +@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) + +package dev.typetype.android.feature.player.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import androidx.media3.common.VideoSize +import dev.typetype.android.domain.stream.Stream +import dev.typetype.android.feature.player.codecDisplayName +import dev.typetype.android.feature.player.codecSelectionKey +import dev.typetype.android.services.MergedStreamMediaKeys + +internal data class PlaybackActiveVideoFormat( + val height: Int, + val codec: String, + val framesPerSecond: Int?, + val hdr: Boolean, +) { + val qualityLabel: String + get() = listOfNotNull( + "${height}p", + framesPerSecond?.let { "$it fps" }, + "HDR".takeIf { hdr }, + ).joinToString(" · ") +} + +@Composable +internal fun rememberActiveVideoFormat( + player: Player, + stream: Stream? = null, +): PlaybackActiveVideoFormat? { + var format by remember(player, stream?.id) { + mutableStateOf( + player.currentTracks.selectedVideoFormat(player.videoSize) + ?: player.selectedSabrSourceFormat(stream), + ) + } + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + format = tracks.selectedVideoFormat(player.videoSize) + ?: player.selectedSabrSourceFormat(stream) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + format = player.currentTracks.selectedVideoFormat(videoSize) + ?: player.selectedSabrSourceFormat(stream) + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + return format +} + +private fun Tracks.selectedVideoFormat(videoSize: VideoSize): PlaybackActiveVideoFormat? { + val formats = groups + .asSequence() + .filter { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + .flatMap { group -> + (0 until group.length) + .asSequence() + .filter(group::isTrackSelected) + .map(group::getTrackFormat) + } + .filter { it.height > 0 } + .toList() + val format = formats + .filter { videoSize.height <= 0 || it.height == videoSize.height } + .maxByOrNull { it.bitrate } + ?: formats.maxByOrNull { it.height } + ?: return null + return PlaybackActiveVideoFormat( + height = videoSize.height.takeIf { it > 0 } ?: format.height, + codec = format.codecs.orEmpty().codecSelectionKey().codecDisplayName(), + framesPerSecond = format.frameRate.takeIf { it > 0f }?.toInt(), + hdr = format.colorInfo?.colorTransfer in HDR_TRANSFERS, + ) +} + +private fun Player.selectedSabrSourceFormat( + stream: Stream?, +): PlaybackActiveVideoFormat? { + val videoItag = currentMediaItem?.requestMetadata?.extras + ?.getInt(MergedStreamMediaKeys.EXTRA_SABR_VIDEO_ITAG) + ?.takeIf { it > 0 } ?: return null + val source = stream?.sabrVideoStreams?.firstOrNull { it.itag == videoItag } ?: return null + return PlaybackActiveVideoFormat( + height = source.height, + codec = source.codec.orEmpty().codecSelectionKey().codecDisplayName(), + framesPerSecond = source.fps.takeIf { it > 0 }, + hdr = false, + ) +} + +private val HDR_TRANSFERS = setOf(C.COLOR_TRANSFER_ST2084, C.COLOR_TRANSFER_HLG) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackCurrentVideoHeight.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackCurrentVideoHeight.kt deleted file mode 100644 index ba706c0f..00000000 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackCurrentVideoHeight.kt +++ /dev/null @@ -1,39 +0,0 @@ -package dev.typetype.android.feature.player.components - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.media3.common.C -import androidx.media3.common.Player -import androidx.media3.common.Tracks - -@Composable -fun rememberCurrentVideoHeight(player: Player): Int? { - var height by remember(player) { mutableStateOf(player.currentTracks.selectedVideoHeight()) } - DisposableEffect(player) { - val listener = object : Player.Listener { - override fun onTracksChanged(tracks: Tracks) { - height = tracks.selectedVideoHeight() - } - } - player.addListener(listener) - onDispose { player.removeListener(listener) } - } - return height -} - -private fun Tracks.selectedVideoHeight(): Int? = - groups - .asSequence() - .filter { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } - .flatMap { group -> - (0 until group.length) - .asSequence() - .filter(group::isTrackSelected) - .map { index -> group.getTrackFormat(index).height } - } - .filter { it > 0 } - .maxOrNull() diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsRows.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsRows.kt index 31760e9b..44de300e 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsRows.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsRows.kt @@ -16,7 +16,7 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch +import dev.typetype.android.core.ui.components.TypeTypeSwitch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -137,7 +137,7 @@ fun PlaybackOptionsToggleRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Switch( + TypeTypeSwitch( checked = checked, onCheckedChange = null, enabled = enabled, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsSheet.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsSheet.kt index f4c85ee5..977500d1 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsSheet.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlaybackOptionsSheet.kt @@ -56,9 +56,10 @@ internal fun PlaybackOptionsSheet( onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val currentVideoHeight = rememberCurrentVideoHeight(player) + val activeVideoFormat = rememberActiveVideoFormat(player, stream) val autoLabel = stringResource(R.string.playback_options_auto) val recommendedLabel = stringResource(R.string.playback_options_recommended) + val smartLabel = stringResource(R.string.playback_options_smart) val isServerSabr = stream.playbackContract == StreamPlaybackContract.ServerSabr val usesRecommendedSelection = isServerSabr || !stream.hasAdaptiveSource() val codecOptions = remember(stream, codecSupport) { stream.codecOptions(codecSupport) } @@ -68,8 +69,8 @@ internal fun PlaybackOptionsSheet( val allCodecOptions = listOf( PlaybackPickerOption( RECOMMENDED_CODEC_KEY, - recommendedLabel, - stringResource(R.string.playback_options_recommended_summary), + smartLabel, + stringResource(R.string.playback_options_smart_summary), ), ) + codecOptions val automaticOption = if (usesRecommendedSelection) { @@ -86,12 +87,16 @@ internal fun PlaybackOptionsSheet( ) } val allQualityOptions = listOf(automaticOption) + qualityOptions - val selectedCodecLabel = allCodecOptions - .firstOrNull { it.key == selectedCodec } - ?.label - ?: recommendedLabel - val selectedQualityLabel = if (selectedQuality == AUTO_QUALITY_KEY && currentVideoHeight != null) { - stringResource(R.string.playback_options_quality_auto_current, autoLabel, "${currentVideoHeight}p") + val selectedCodecLabel = if (selectedCodec == RECOMMENDED_CODEC_KEY) { + activeVideoFormat?.let { "$smartLabel · ${it.codec}" } ?: smartLabel + } else { + allCodecOptions.firstOrNull { it.key == selectedCodec }?.label ?: selectedCodec + } + val selectedQualityLabel = if ( + selectedQuality in setOf(AUTO_QUALITY_KEY, RECOMMENDED_QUALITY_KEY) && + activeVideoFormat != null + ) { + activeVideoFormat.qualityLabel } else { allQualityOptions.firstOrNull { it.key == selectedQuality }?.label ?: if (selectedQuality == AUTO_QUALITY_KEY) autoLabel else selectedQuality @@ -151,7 +156,7 @@ internal fun PlaybackOptionsSheet( title = stringResource(R.string.playback_options_codec), options = allCodecOptions, selectedKey = selectedCodec, - emptyLabel = recommendedLabel, + emptyLabel = smartLabel, onBack = { page = PlaybackOptionsPage.Main }, onSelect = { key -> key?.let(onSelectCodec) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerAccessibility.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerAccessibility.kt new file mode 100644 index 00000000..12b1f333 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerAccessibility.kt @@ -0,0 +1,29 @@ +package dev.typetype.android.feature.player.components + +import android.view.accessibility.AccessibilityManager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext + +@Composable +internal fun rememberAccessiblePlayerControls(manualModeEnabled: Boolean): Boolean { + val context = LocalContext.current + val manager = remember(context) { + context.getSystemService(AccessibilityManager::class.java) + } + var touchExplorationEnabled by remember(manager) { + mutableStateOf(manager?.isTouchExplorationEnabled == true) + } + DisposableEffect(manager) { + val listener = AccessibilityManager.TouchExplorationStateChangeListener { enabled -> + touchExplorationEnabled = enabled + } + manager?.addTouchExplorationStateChangeListener(listener) + onDispose { manager?.removeTouchExplorationStateChangeListener(listener) } + } + return manualModeEnabled || touchExplorationEnabled +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerBufferingIndicator.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerBufferingIndicator.kt new file mode 100644 index 00000000..2a179ac6 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerBufferingIndicator.kt @@ -0,0 +1,24 @@ +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +internal fun PlayerBufferingIndicator( + visible: Boolean, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = modifier, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } +} 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 ff6f52f2..0885843d 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 @@ -27,12 +27,17 @@ import dev.typetype.android.R internal fun PlayerCenterControls( player: Player, isFullscreen: Boolean, + compact: Boolean = false, modifier: Modifier = Modifier, ) { val playPauseState = rememberPlayPauseButtonState(player) val seekBackState = rememberSeekBackButtonState(player) val seekForwardState = rememberSeekForwardButtonState(player) - val spacing = if (isFullscreen) 44.dp else 26.dp + val spacing = when { + isFullscreen -> 44.dp + compact -> 18.dp + else -> 26.dp + } Row( modifier = modifier, @@ -44,16 +49,34 @@ internal fun PlayerCenterControls( contentDescription = stringResource(R.string.player_rewind), enabled = seekBackState.isEnabled, onClick = { seekBackState.onClick() }, - buttonSize = if (isFullscreen) 62.dp else 50.dp, - iconSize = if (isFullscreen) 32.dp else 26.dp, + buttonSize = when { + isFullscreen -> 62.dp + compact -> 40.dp + else -> 50.dp + }, + iconSize = when { + isFullscreen -> 32.dp + compact -> 22.dp + else -> 26.dp + }, ) PlayerCenterButton( iconRes = if (playPauseState.showPlay) R.drawable.ic_play else R.drawable.ic_pause, - contentDescription = stringResource(R.string.player_play_pause), + contentDescription = stringResource( + if (playPauseState.showPlay) R.string.player_play else R.string.player_pause, + ), enabled = playPauseState.isEnabled, onClick = { playPauseState.onClick() }, - buttonSize = if (isFullscreen) 74.dp else 62.dp, - iconSize = if (isFullscreen) 48.dp else 38.dp, + buttonSize = when { + isFullscreen -> 74.dp + compact -> 48.dp + else -> 62.dp + }, + iconSize = when { + isFullscreen -> 48.dp + compact -> 30.dp + else -> 38.dp + }, prominent = true, ) PlayerCenterButton( @@ -61,8 +84,16 @@ internal fun PlayerCenterControls( contentDescription = stringResource(R.string.player_forward), enabled = seekForwardState.isEnabled, onClick = { seekForwardState.onClick() }, - buttonSize = if (isFullscreen) 62.dp else 50.dp, - iconSize = if (isFullscreen) 32.dp else 26.dp, + buttonSize = when { + isFullscreen -> 62.dp + compact -> 40.dp + else -> 50.dp + }, + iconSize = when { + 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 1a0a1d4c..cd44edfc 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 @@ -2,6 +2,7 @@ package dev.typetype.android.feature.player.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -22,6 +23,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R @@ -46,9 +48,16 @@ fun PlayerControls( chaptersAvailable: Boolean = false, sponsorBlockSegments: List = emptyList(), ) { - Box(modifier = modifier) { - TopScrim(modifier = Modifier.align(Alignment.TopCenter)) - BottomScrim(modifier = Modifier.align(Alignment.BottomCenter)) + BoxWithConstraints(modifier = modifier) { + val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT + TopScrim( + compact = compactControls, + modifier = Modifier.align(Alignment.TopCenter), + ) + BottomScrim( + compact = compactControls, + modifier = Modifier.align(Alignment.BottomCenter), + ) PlayerTopBar( title = title, onNavigateBack = onNavigateBack, @@ -60,24 +69,35 @@ fun PlayerControls( isFullscreen = isFullscreen, isPipAvailable = isPipAvailable, chaptersAvailable = chaptersAvailable, + compact = compactControls, modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars), + .testTag(PLAYER_TOP_CONTROLS_TAG) + .then( + if (isFullscreen) { + Modifier.windowInsetsPadding(WindowInsets.statusBars) + } else { + Modifier + }, + ), ) PlayerCenterControls( player = player, isFullscreen = isFullscreen, - modifier = Modifier.align(Alignment.Center), + compact = compactControls, + modifier = Modifier.align(Alignment.Center).testTag(PLAYER_CENTER_CONTROLS_TAG), ) BottomBar( player = player, sponsorBlockSegments = sponsorBlockSegments, isFullscreen = isFullscreen, + compact = compactControls, onToggleFullscreen = onToggleFullscreen, modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .testTag(PLAYER_BOTTOM_CONTROLS_TAG) .then( if (isFullscreen) { Modifier.windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) @@ -95,11 +115,11 @@ fun PlayerControls( } @Composable -private fun TopScrim(modifier: Modifier = Modifier) { +private fun TopScrim(compact: Boolean, modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() - .height(112.dp) + .height(if (compact) 72.dp else 112.dp) .background( Brush.verticalGradient( colors = listOf(Color.Black.copy(alpha = 0.68f), Color.Transparent), @@ -109,11 +129,11 @@ private fun TopScrim(modifier: Modifier = Modifier) { } @Composable -private fun BottomScrim(modifier: Modifier = Modifier) { +private fun BottomScrim(compact: Boolean, modifier: Modifier = Modifier) { Box( modifier = modifier .fillMaxWidth() - .height(152.dp) + .height(if (compact) 88.dp else 152.dp) .background( Brush.verticalGradient( colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.72f)), @@ -127,6 +147,7 @@ private fun BottomBar( player: Player, sponsorBlockSegments: List, isFullscreen: Boolean, + compact: Boolean, onToggleFullscreen: () -> Unit, modifier: Modifier = Modifier, ) { @@ -137,7 +158,13 @@ private fun BottomBar( } Row( modifier = modifier - .height(if (isFullscreen) 52.dp else 40.dp) + .height( + when { + isFullscreen -> 52.dp + compact -> 36.dp + else -> 40.dp + }, + ) .then(backgroundModifier) .padding(start = if (isFullscreen) 8.dp else 2.dp, end = 2.dp), verticalAlignment = Alignment.CenterVertically, @@ -150,7 +177,13 @@ private fun BottomBar( ) IconButton( onClick = onToggleFullscreen, - modifier = Modifier.size(if (isFullscreen) 48.dp else 40.dp), + modifier = Modifier.size( + when { + isFullscreen -> 48.dp + compact -> 36.dp + else -> 40.dp + }, + ), ) { Icon( painter = painterResource( @@ -162,3 +195,8 @@ private fun BottomBar( } } } + +internal const val PLAYER_TOP_CONTROLS_TAG = "player_top_controls" +internal const val PLAYER_CENTER_CONTROLS_TAG = "player_center_controls" +internal const val PLAYER_BOTTOM_CONTROLS_TAG = "player_bottom_controls" +private val COMPACT_CONTROLS_HEIGHT = 180.dp diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDoubleTapAction.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDoubleTapAction.kt new file mode 100644 index 00000000..579265fb --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDoubleTapAction.kt @@ -0,0 +1,19 @@ +package dev.typetype.android.feature.player.components + +internal enum class PlayerDoubleTapAction { + Rewind, + TogglePlayback, + Forward, +} + +internal fun doubleTapAction(x: Float, width: Float): PlayerDoubleTapAction { + if (width <= 0f) return PlayerDoubleTapAction.TogglePlayback + return when { + x < width / 3f -> PlayerDoubleTapAction.Rewind + x > width * 2f / 3f -> PlayerDoubleTapAction.Forward + else -> PlayerDoubleTapAction.TogglePlayback + } +} + +internal fun PlayerDoubleTapAction.isEnabled(seekEnabled: Boolean): Boolean = + this == PlayerDoubleTapAction.TogglePlayback || seekEnabled diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt new file mode 100644 index 00000000..def36143 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerFullscreenExitGesture.kt @@ -0,0 +1,131 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import kotlin.math.abs + +@Stable +internal class PlayerFullscreenExitGestureState { + var progress by mutableFloatStateOf(0f) + private set + + fun update(value: Float) { + progress = value.coerceIn(0f, 1f) + } + + fun reset() { + progress = 0f + } +} + +@Composable +internal fun rememberPlayerFullscreenExitGestureState(): PlayerFullscreenExitGestureState = + remember { PlayerFullscreenExitGestureState() } + +@Composable +internal fun PlayerFullscreenExitGestureBox( + enabled: Boolean, + onGestureFeedback: () -> Unit, + onExitFullscreen: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + val state = rememberPlayerFullscreenExitGestureState() + val currentExitFullscreen by rememberUpdatedState(onExitFullscreen) + LaunchedEffect(enabled) { + if (!enabled) state.reset() + } + Box( + modifier = modifier + .background(Color.Black) + .clipToBounds() + .playerFullscreenExitGesture( + enabled = enabled, + state = state, + onGestureFeedback = onGestureFeedback, + onExitFullscreen = { currentExitFullscreen() }, + ) + .graphicsLayer { + val scale = 1f - state.progress * 0.12f + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 1f) + }, + content = content, + ) +} + +internal fun Modifier.playerFullscreenExitGesture( + enabled: Boolean, + state: PlayerFullscreenExitGestureState, + onGestureFeedback: () -> Unit, + onExitFullscreen: () -> Unit, +): Modifier = if (!enabled) { + this +} else { + pointerInput(state, enabled) { + val directionThresholdPx = 18.dp.toPx() + val completionThresholdPx = 64.dp.toPx() + awaitEachGesture { + val down = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, + ) + val startsInMiddle = down.position.x in (size.width * 0.35f)..(size.width * 0.65f) + var lastPosition = down.position + var totalDrag = Offset.Zero + var locked = false + var completed = false + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + val delta = change.position - lastPosition + lastPosition = change.position + totalDrag += delta + if (!locked) { + if (abs(totalDrag.x) < directionThresholdPx && + abs(totalDrag.y) < directionThresholdPx + ) continue + if (!startsInMiddle || totalDrag.y <= abs(totalDrag.x)) break + locked = true + onGestureFeedback() + } + if (!change.pressed) { + completed = totalDrag.y >= completionThresholdPx + break + } + change.consume() + state.update(totalDrag.y / completionThresholdPx) + } + } finally { + if (completed) { + state.reset() + onExitFullscreen() + } else { + state.reset() + } + } + } + } +} 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 d2a3785f..d9161584 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 @@ -28,23 +28,24 @@ import dev.typetype.android.feature.player.state.PlayerGestureState import dev.typetype.android.feature.player.state.ResizeMode import kotlin.math.abs -private const val DOUBLE_TAP_SEEK_INCREMENT_MS = 10_000L private const val SEEK_DRAG_MS_PER_PIXEL = 80f private const val DIRECTION_LOCK_THRESHOLD_PX = 18f 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, state: PlayerGestureState, - onTogglePlayPause: () -> Unit, + onSingleTap: () -> Unit, onAdjustBrightness: (Float) -> Unit, onAdjustVolume: (Float) -> Unit, modifier: Modifier = Modifier, @@ -52,6 +53,7 @@ fun PlayerGestureLayer( isFullscreen: Boolean = false, onEnterFullscreenGesture: () -> Unit = {}, onExitFullscreenGesture: () -> Unit = {}, + fullscreenExitGestureEnabled: Boolean = true, config: PlayerGestureConfig = PlayerGestureConfig(), ) { var savedSpeed by remember { mutableFloatStateOf(1f) } @@ -103,12 +105,12 @@ fun PlayerGestureLayer( width = size.width.toFloat(), ) val allowed = when (candidate) { - DragMode.Seek -> isFullscreen && config.swipeSeekEnabled + DragMode.Seek -> config.swipeSeekEnabled DragMode.Brightness, DragMode.Volume, -> isFullscreen && config.swipeBrightnessVolumeEnabled DragMode.FullscreenEnter -> !isFullscreen - DragMode.FullscreenExit -> true + DragMode.FullscreenExit -> isFullscreen && fullscreenExitGestureEnabled DragMode.None -> false } if (!allowed) continue @@ -141,21 +143,30 @@ fun PlayerGestureLayer( } .pointerInput(player, config) { detectTapGestures( - onTap = { onTogglePlayPause() }, + onTap = { onSingleTap() }, onDoubleTap = { offset -> - if (!config.doubleTapSeekEnabled) return@detectTapGestures + val action = doubleTapAction(offset.x, size.width.toFloat()) + if (!action.isEnabled(config.doubleTapSeekEnabled)) { + return@detectTapGestures + } onGestureFeedback() - val side = if (offset.x < size.width / 2f) GestureSide.Left else GestureSide.Right - val current = player.currentPosition - val target = if (side == GestureSide.Left) { - (current - DOUBLE_TAP_SEEK_INCREMENT_MS).coerceAtLeast(0L) - } else { - current + DOUBLE_TAP_SEEK_INCREMENT_MS + when (action) { + PlayerDoubleTapAction.Rewind -> { + player.seekTo( + (player.currentPosition - config.seekIncrementMs) + .coerceAtLeast(0L), + ) + state.showSeekHint(GestureSide.Left, config.doubleTapSeekSeconds) + } + PlayerDoubleTapAction.TogglePlayback -> { + if (player.isPlaying) player.pause() else player.play() + state.showPlaybackHint(player.isPlaying) + } + PlayerDoubleTapAction.Forward -> { + player.seekTo(player.currentPosition + config.seekIncrementMs) + state.showSeekHint(GestureSide.Right, config.doubleTapSeekSeconds) + } } - player.seekTo(target) - state.seekHintSide.value = side - state.seekHintSeconds.floatValue = (DOUBLE_TAP_SEEK_INCREMENT_MS / 1_000f) - state.seekHintPulse.longValue += 1L }, onLongPress = { if (!config.longPressSpeedEnabled) return@detectTapGestures @@ -190,6 +201,7 @@ fun PlayerGestureLayer( ), ) { SeekHintOverlay(state = state) + PlaybackHintOverlay(state = state) PlayerLevelOverlay( visible = state.brightnessOverlayActive.value, fraction = state.brightnessFraction.floatValue, @@ -211,6 +223,9 @@ fun PlayerGestureLayer( } } +private val PlayerGestureConfig.seekIncrementMs: Long + get() = doubleTapSeekSeconds.coerceIn(5, 30) * 1_000L + private fun handleDragMode( player: Player, state: PlayerGestureState, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt index 1dd2cf75..c0670290 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureOverlays.kt @@ -17,6 +17,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -58,6 +62,34 @@ internal fun SeekHintOverlay(state: PlayerGestureState) { } } +@Composable +internal fun PlaybackHintOverlay(state: PlayerGestureState) { + val playing = state.playbackHintPlaying.value + val pulse = state.playbackHintPulse.longValue + LaunchedEffect(playing, pulse) { + if (playing != null) { + kotlinx.coroutines.delay(SEEK_HINT_VISIBLE_MS.toLong()) + state.playbackHintPlaying.value = null + } + } + if (playing != null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .background(Color.Black.copy(alpha = 0.68f)) + .padding(18.dp), + ) { + Icon( + imageVector = if (playing) Icons.Filled.PlayArrow else Icons.Filled.Pause, + contentDescription = null, + tint = Color.White, + ) + } + } + } +} + @Composable private fun SeekTapWave( side: GestureSide, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLoadingPoster.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLoadingPoster.kt index 3259bf7b..4efd0330 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLoadingPoster.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLoadingPoster.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import coil3.compose.AsyncImage import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.stream.Stream @Composable @@ -19,6 +21,7 @@ internal fun PlayerLoadingPoster( stream: Stream, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current val branding = rememberVideoBranding( sourceUrl = stream.id, title = stream.title, @@ -30,7 +33,7 @@ internal fun PlayerLoadingPoster( contentAlignment = Alignment.Center, ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize(), 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 5e15f29b..2315e1e3 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 @@ -7,13 +7,11 @@ import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -22,14 +20,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.ui.compose.state.rememberPresentationState -import dev.typetype.android.R import dev.typetype.android.domain.stream.Chapter import dev.typetype.android.domain.stream.SponsorBlockSegment import dev.typetype.android.domain.stream.Stream @@ -53,7 +50,7 @@ private const val AUTO_HIDE_DELAY_MS = 3_500L internal fun PlayerSurfaceBox( player: MediaController, stream: Stream, - audioOnlyDefault: AudioOnlyPlaybackDefault, + audioOnlyState: AudioOnlyPlaybackState, selectedCodec: String, selectedQuality: String, selectedAudioKey: String?, @@ -81,9 +78,11 @@ internal fun PlayerSurfaceBox( captionStyles: CaptionStyles = CaptionStyles(), danmakuState: PlayerDanmakuState = PlayerDanmakuState(), onDanmakuAction: (PlayerDanmakuAction) -> Unit = {}, + hostTransitionProgress: () -> Float = { 0f }, ) { val activity = LocalActivity.current val context = LocalContext.current + val hapticFeedback = LocalHapticFeedback.current val audioManager = remember(context) { context.getSystemService(AudioManager::class.java) } @@ -93,10 +92,10 @@ internal fun PlayerSurfaceBox( var controlsVisible by remember { mutableStateOf(true) } var optionsVisible by remember { mutableStateOf(false) } var chaptersVisible by remember { mutableStateOf(false) } - val audioOnlyState = rememberAudioOnlyPlaybackState(player, stream, audioOnlyDefault) + val accessibleControls = rememberAccessiblePlayerControls( + gestureConfig.accessibleControlsEnabled, + ) val audioOnlySnackbar = remember { SnackbarHostState() } - val audioOnlyUnavailable = stringResource(R.string.player_audio_only_unavailable) - val audioOnlyNetworkFailure = stringResource(R.string.error_network_unavailable) val playbackStatus = rememberPlayerPlaybackStatus( player, onRetryPlayback.takeIf { stream.playbackContract == StreamPlaybackContract.ServerSabr }, @@ -107,19 +106,13 @@ internal fun PlayerSurfaceBox( stream.playbackContract == StreamPlaybackContract.ServerSabr && it.key == selectedSubtitleKey } - - LaunchedEffect(Unit) { - gestureState.brightnessFraction.floatValue = playbackBrightnessPercent - ?.let { it / 100f } - ?: activity?.window?.attributes?.screenBrightness - ?.takeIf { it in 0f..1f } - ?: 0.5f - val maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 1 - val currentVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0 - gestureState.volumeFraction.floatValue = - if (maxVolume > 0) currentVolume / maxVolume.toFloat() else 0f - appliedVolumeLevel = currentVolume - } + PlayerGestureInitializationEffect( + state = gestureState, + audioManager = audioManager, + selectedBrightnessPercent = playbackBrightnessPercent, + windowBrightness = activity?.window?.attributes?.screenBrightness, + onVolumeInitialized = { appliedVolumeLevel = it }, + ) PlaybackBrightnessEffect( window = activity?.window, @@ -130,31 +123,42 @@ internal fun PlayerSurfaceBox( window = activity?.window, videoIsPlaying = playbackStatus.isPlaying && !audioOnlyState.active, ) - - LaunchedEffect(controlsVisible, playbackStatus.isPlaying) { - if (controlsVisible && playbackStatus.isPlaying) { + LaunchedEffect(accessibleControls) { + if (accessibleControls) controlsVisible = true + } + LaunchedEffect(controlsVisible, playbackStatus.isPlaying, accessibleControls) { + if (controlsVisible && playbackStatus.isPlaying && !accessibleControls) { delay(AUTO_HIDE_DELAY_MS) controlsVisible = false } } - LaunchedEffect(audioOnlyState.failure) { - val failure = audioOnlyState.failure ?: return@LaunchedEffect - audioOnlySnackbar.showSnackbar( - when (failure) { - AudioOnlyPlaybackFailure.Network -> audioOnlyNetworkFailure - AudioOnlyPlaybackFailure.Unavailable -> audioOnlyUnavailable - }, - ) - audioOnlyState.consumeFailure() - } + PlayerAudioOnlyFailureEffect(audioOnlyState, audioOnlySnackbar) val presentationState = rememberPresentationState(player, keepContentOnReset = true) val surfaceKey = rememberPlayerSurfaceKey(stream.id) - Box(modifier = modifier.background(Color.Black).clipToBounds()) { + val chromeModifier = Modifier.graphicsLayer { + alpha = 1f - hostTransitionProgress().coerceIn(0f, 1f) + } + val gesturesVisible by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() < 0.01f } + } + val controlsAllowedByProgress by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() < 0.99f } + } + PlayerFullscreenExitGestureBox( + enabled = isFullscreen && playbackStatus.acceptsInput && !isInPip, + onGestureFeedback = { + controlsVisible = false + hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onExitFullscreen = onToggleFullscreen, + modifier = modifier, + ) { if (audioOnlyState.active) { DeArrowAudioOnlyPoster( stream = stream, + isPlaying = playbackStatus.isPlaying, modifier = Modifier.fillMaxSize(), ) } else { @@ -170,41 +174,41 @@ internal fun PlayerSurfaceBox( } if (!audioOnlyState.active && (!isInPip || externalSubtitle != null)) { - DanmakuOverlay( - player = player, - state = danmakuState, - visible = !isInPip, - modifier = Modifier.fillMaxSize(), - ) - PlayerSubtitleOverlay( - player = player, - controlsVisible = controlsVisible && !isInPip, - subtitlesVisible = selectedSubtitleKey != null, - externalSource = externalSubtitle, - loadExternalCues = loadSubtitleCues, - captionStyles = captionStyles, - modifier = Modifier.fillMaxSize(), - ) + Box(modifier = chromeModifier.fillMaxSize()) { + DanmakuOverlay( + player = player, + state = danmakuState, + visible = !isInPip, + modifier = Modifier.fillMaxSize(), + ) + PlayerSubtitleOverlay( + player = player, + controlsVisible = controlsVisible && !isInPip, + subtitlesVisible = selectedSubtitleKey != null, + externalSource = externalSubtitle, + loadExternalCues = loadSubtitleCues, + captionStyles = captionStyles, + modifier = Modifier.fillMaxSize(), + ) + } } if (presentationState.coverSurface && !audioOnlyState.active) { PlayerLoadingPoster(stream = stream, modifier = Modifier.fillMaxSize()) } - AnimatedVisibility( + PlayerBufferingIndicator( visible = playbackStatus.isBuffering && !presentationState.coverSurface && !isInPip, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.Center), - ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) - } + modifier = chromeModifier.align(Alignment.Center), + ) - if (!isInPip && playbackStatus.acceptsInput) { + if (!isInPip && playbackStatus.acceptsInput && !accessibleControls && + gesturesVisible + ) { PlayerGestureLayer( player = player, state = gestureState, - onTogglePlayPause = { + onSingleTap = { controlsVisible = !controlsVisible }, onAdjustBrightness = { fraction -> @@ -225,21 +229,27 @@ internal fun PlayerSurfaceBox( } } }, - onGestureFeedback = { controlsVisible = false }, + onGestureFeedback = { + controlsVisible = false + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + }, isFullscreen = isFullscreen, onEnterFullscreenGesture = { if (!isFullscreen) onToggleFullscreen() }, onExitFullscreenGesture = { - if (isFullscreen) onToggleFullscreen() else onNavigateBack() + if (isFullscreen) onToggleFullscreen() }, + fullscreenExitGestureEnabled = false, config = gestureConfig, modifier = Modifier.fillMaxSize(), ) } AnimatedVisibility( - visible = controlsVisible && !isInPip && playbackStatus.acceptsInput, + visible = controlsAllowedByProgress && + (controlsVisible || accessibleControls) && + !isInPip && playbackStatus.acceptsInput, enter = fadeIn(), exit = fadeOut(), ) { @@ -267,7 +277,7 @@ internal fun PlayerSurfaceBox( isPipAvailable = isPipAvailable, chaptersAvailable = chapters.isNotEmpty(), sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, - modifier = Modifier.fillMaxSize(), + modifier = chromeModifier.fillMaxSize(), ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceEffects.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceEffects.kt new file mode 100644 index 00000000..096b67b1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceEffects.kt @@ -0,0 +1,49 @@ +package dev.typetype.android.feature.player.components + +import android.media.AudioManager +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R +import dev.typetype.android.feature.player.state.PlayerGestureState + +@Composable +internal fun PlayerGestureInitializationEffect( + state: PlayerGestureState, + audioManager: AudioManager?, + selectedBrightnessPercent: Int?, + windowBrightness: Float?, + onVolumeInitialized: (Int) -> Unit, +) { + LaunchedEffect(Unit) { + state.brightnessFraction.floatValue = selectedBrightnessPercent + ?.let { it / 100f } + ?: windowBrightness?.takeIf { it in 0f..1f } + ?: 0.5f + val maxVolume = audioManager?.getStreamMaxVolume(AudioManager.STREAM_MUSIC) ?: 1 + val currentVolume = audioManager?.getStreamVolume(AudioManager.STREAM_MUSIC) ?: 0 + state.volumeFraction.floatValue = + if (maxVolume > 0) currentVolume / maxVolume.toFloat() else 0f + onVolumeInitialized(currentVolume) + } +} + +@Composable +internal fun PlayerAudioOnlyFailureEffect( + state: AudioOnlyPlaybackState, + snackbar: SnackbarHostState, +) { + val unavailable = stringResource(R.string.player_audio_only_unavailable) + val networkFailure = stringResource(R.string.error_network_unavailable) + LaunchedEffect(state.failure) { + val failure = state.failure ?: return@LaunchedEffect + snackbar.showSnackbar( + when (failure) { + AudioOnlyPlaybackFailure.Network -> networkFailure + AudioOnlyPlaybackFailure.Unavailable -> unavailable + }, + ) + state.consumeFailure() + } +} 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 66119891..29a2b29a 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 @@ -26,12 +26,20 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval +import dev.typetype.android.R import dev.typetype.android.domain.stream.SponsorBlockSegment import kotlin.time.Duration.Companion.milliseconds @@ -60,6 +68,8 @@ fun PlayerTimeBar( val durationMs = progressState.durationMs.coerceAtLeast(0L) val displayedPosMs = scrubPositionMs ?: progressState.currentPositionMs.coerceIn(0L, durationMs) + val positionLabel = formatPlayerTime(displayedPosMs) + val durationLabel = formatPlayerTime(durationMs) Row( modifier = modifier, @@ -67,7 +77,7 @@ fun PlayerTimeBar( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = formatTime(displayedPosMs), + text = positionLabel, style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), color = Color.White, modifier = if (compact) { @@ -88,13 +98,19 @@ fun PlayerTimeBar( scrubPositionMs = null }, onScrubCancelled = { scrubPositionMs = null }, + accessibilityLabel = stringResource(R.string.player_timeline), + accessibilityStateDescription = stringResource( + R.string.player_timeline_position, + positionLabel, + durationLabel, + ), modifier = Modifier .weight(1f) .padding(horizontal = if (compact) 2.dp else 4.dp) .height(if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), ) Text( - text = formatTime(durationMs), + text = durationLabel, style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), color = Color.White.copy(alpha = 0.7f), modifier = if (compact) { @@ -115,12 +131,28 @@ internal fun TimelineTrack( onScrub: (Long) -> Unit, onScrubFinished: (Long) -> Unit, onScrubCancelled: () -> Unit, + accessibilityLabel: String, + accessibilityStateDescription: String, modifier: Modifier = Modifier, ) { val activeColor = MaterialTheme.colorScheme.primary val inactiveColor = Color.White.copy(alpha = 0.3f) Box( modifier = modifier + .semantics { + contentDescription = accessibilityLabel + stateDescription = accessibilityStateDescription + progressBarRangeInfo = ProgressBarRangeInfo( + current = positionMs.toFloat(), + range = 0f..durationMs.coerceAtLeast(1L).toFloat(), + ) + setProgress { target -> + val targetMs = target.toLong().coerceIn(0L, durationMs.coerceAtLeast(0L)) + onScrub(targetMs) + onScrubFinished(targetMs) + true + } + } .pointerInput(durationMs) { detectTapGestures { offset -> val targetMs = offset.x.toPositionMs(size.width.toFloat(), durationMs) @@ -207,7 +239,7 @@ private fun Float.toPositionMs(width: Float, durationMs: Long): Long { return ((this / width).coerceIn(0f, 1f) * durationMs).toLong() } -private fun formatTime(ms: Long): String { +internal fun formatPlayerTime(ms: Long): String { val total = ms.milliseconds.inWholeSeconds val h = total / 3600 val m = (total % 3600) / 60 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 b539bfe2..fd3d0b6b 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 @@ -39,10 +39,11 @@ internal fun PlayerTopBar( isFullscreen: Boolean, isPipAvailable: Boolean, chaptersAvailable: Boolean, + compact: Boolean = false, modifier: Modifier = Modifier, ) { Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { - BackButton(onNavigateBack) + BackButton(onNavigateBack, compact) if (isFullscreen) { Text( text = title, @@ -64,13 +65,18 @@ internal fun PlayerTopBar( isFullscreen, isPipAvailable, chaptersAvailable, + compact, ) } } @Composable -private fun BackButton(onNavigateBack: () -> Unit) { - OverlayIconButton(onClick = onNavigateBack, modifier = Modifier.padding(8.dp)) { +private fun BackButton(onNavigateBack: () -> Unit, compact: Boolean) { + OverlayIconButton( + onClick = onNavigateBack, + compact = compact, + modifier = Modifier.padding(if (compact) 4.dp else 8.dp), + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.player_back), @@ -89,13 +95,14 @@ private fun TopActions( isFullscreen: Boolean, isPipAvailable: Boolean, chaptersAvailable: Boolean, + compact: Boolean, ) { Row( - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(if (compact) 4.dp else 8.dp), verticalAlignment = Alignment.CenterVertically, ) { if (isFullscreen) { - OverlayIconButton(onCycleResizeMode) { + OverlayIconButton(onCycleResizeMode, compact = compact) { Icon( imageVector = resizeMode.icon(), contentDescription = stringResource(R.string.player_resize_mode), @@ -104,7 +111,7 @@ private fun TopActions( } } if (isPipAvailable) { - OverlayIconButton(onEnterPip) { + OverlayIconButton(onEnterPip, compact = compact) { Icon( painter = painterResource(R.drawable.ic_pip), contentDescription = stringResource(R.string.player_pip), @@ -113,7 +120,7 @@ private fun TopActions( } } if (chaptersAvailable) { - OverlayIconButton(onOpenChapters) { + OverlayIconButton(onOpenChapters, compact = compact) { Icon( imageVector = Icons.AutoMirrored.Filled.List, contentDescription = stringResource(R.string.player_chapters), @@ -121,7 +128,7 @@ private fun TopActions( ) } } - OverlayIconButton(onOpenOptions) { + OverlayIconButton(onOpenOptions, compact = compact) { Icon( imageVector = Icons.Filled.Settings, contentDescription = stringResource(R.string.player_playback_options), @@ -135,11 +142,12 @@ private fun TopActions( private fun OverlayIconButton( onClick: () -> Unit, modifier: Modifier = Modifier, + compact: Boolean = false, content: @Composable () -> Unit, ) { IconButton( onClick = onClick, - modifier = modifier.padding(2.dp).size(40.dp), + modifier = modifier.padding(2.dp).size(if (compact) 36.dp else 40.dp), content = content, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/RelatedStreamsSection.kt b/app/src/main/java/dev/typetype/android/feature/player/components/RelatedStreamsSection.kt index 3cbc16aa..cc1b1947 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/RelatedStreamsSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/RelatedStreamsSection.kt @@ -1,15 +1,27 @@ package dev.typetype.android.feature.player.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.Switch +import androidx.compose.material3.TextButton +import dev.typetype.android.core.ui.components.TypeTypeSwitch import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -30,6 +42,10 @@ fun RelatedStreamsSection( ) { val visibleVideos = videos.filterNot { menuScope.isHidden(it) } if (visibleVideos.isEmpty()) return + var visibleCount by remember(videos) { mutableIntStateOf(INITIAL_RELATED_VIDEO_COUNT) } + val view = LocalView.current + var loaderNearViewport by remember(videos) { mutableStateOf(false) } + val videosToRender = visibleVideos.take(visibleCount) Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -50,9 +66,9 @@ fun RelatedStreamsSection( color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 8.dp), ) - Switch(checked = autoplayEnabled, onCheckedChange = onAutoplayChange) + TypeTypeSwitch(checked = autoplayEnabled, onCheckedChange = onAutoplayChange) } - visibleVideos.forEach { video -> + videosToRender.forEach { video -> RelatedVideoCard( video = video, menuItemState = menuScope.stateFor(video), @@ -62,5 +78,26 @@ fun RelatedStreamsSection( modifier = Modifier.fillMaxWidth(), ) } + if (visibleVideos.size > visibleCount) { + LaunchedEffect(loaderNearViewport, visibleCount) { + if (loaderNearViewport && visibleVideos.size > visibleCount) { + visibleCount = (visibleCount + RELATED_VIDEO_PAGE_SIZE) + .coerceAtMost(visibleVideos.size) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .onGloballyPositioned { layoutCoordinates -> + loaderNearViewport = layoutCoordinates.positionInRoot().y <= + view.height + RELATED_AUTO_LOAD_MARGIN_PX + }, + ) + } } } + +private const val INITIAL_RELATED_VIDEO_COUNT = 6 +private const val RELATED_VIDEO_PAGE_SIZE = 12 +private const val RELATED_AUTO_LOAD_MARGIN_PX = 420f diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt index 5ca11aa2..13e00664 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/UploaderCard.kt @@ -30,6 +30,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl @Composable fun UploaderCard( @@ -43,12 +45,13 @@ fun UploaderCard( onCardClick: () -> Unit = {}, onSubscribeClick: () -> Unit = {}, ) { + val serverBaseUrl = LocalServerBaseUrl.current Row( modifier = modifier.fillMaxWidth().clickable(onClick = onCardClick), verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( - model = avatarUrl, + model = buildImageUrl(serverBaseUrl, avatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/FullscreenOrientationState.kt b/app/src/main/java/dev/typetype/android/feature/player/host/FullscreenOrientationState.kt index f57186e7..f280c7d4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/FullscreenOrientationState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/FullscreenOrientationState.kt @@ -8,6 +8,7 @@ internal enum class DeviceOrientation { Portrait, Landscape, Other } internal data class FullscreenOrientationState( val locksLandscape: Boolean = false, val suppressesLandscapeEntry: Boolean = false, + val restoresPortraitOnExit: Boolean = false, ) { fun onUserRequest( fullscreen: Boolean, @@ -17,6 +18,7 @@ internal data class FullscreenOrientationState( state = copy( locksLandscape = true, suppressesLandscapeEntry = false, + restoresPortraitOnExit = orientation == DeviceOrientation.Portrait, ), fullscreenRequest = true, ) @@ -25,6 +27,7 @@ internal data class FullscreenOrientationState( state = copy( locksLandscape = false, suppressesLandscapeEntry = orientation == DeviceOrientation.Landscape, + restoresPortraitOnExit = locksLandscape, ), fullscreenRequest = false, ) @@ -54,6 +57,7 @@ internal data class FullscreenOrientationState( state = copy( locksLandscape = locksLandscape && isFullscreen, suppressesLandscapeEntry = false, + restoresPortraitOnExit = false, ), fullscreenRequest = false.takeIf { isFullscreen && !locksLandscape }, ) @@ -73,8 +77,16 @@ internal data class FullscreenOrientationState( companion object { val Saver: Saver = listSaver( - save = { listOf(it.locksLandscape, it.suppressesLandscapeEntry) }, - restore = { FullscreenOrientationState(it[0], it[1]) }, + save = { + listOf(it.locksLandscape, it.suppressesLandscapeEntry, it.restoresPortraitOnExit) + }, + restore = { + FullscreenOrientationState( + locksLandscape = it[0], + suppressesLandscapeEntry = it[1], + restoresPortraitOnExit = it.getOrNull(2) == true, + ) + }, ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/MiniPlayerRuntime.kt b/app/src/main/java/dev/typetype/android/feature/player/host/MiniPlayerRuntime.kt index e970f7f0..fd822976 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/MiniPlayerRuntime.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/MiniPlayerRuntime.kt @@ -17,14 +17,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -35,14 +33,8 @@ import androidx.media3.common.Player import androidx.media3.session.MediaController import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding -import dev.typetype.android.feature.player.DevicePlaybackCodecSupport import dev.typetype.android.feature.player.PlayerViewModel -import dev.typetype.android.feature.player.bindStreamToController import dev.typetype.android.feature.player.components.MiniPlayerBar -import dev.typetype.android.feature.player.initialAudioKey -import dev.typetype.android.feature.player.initialQuality -import dev.typetype.android.feature.player.initialSubtitleKey -import dev.typetype.android.feature.player.normalizeDefaultPlaybackSpeed import dev.typetype.android.feature.player.sleep.PlaybackSleepTimerViewModel import dev.typetype.android.feature.player.sleep.summary @@ -58,50 +50,7 @@ internal fun MiniPlayerRuntime( val state by viewModel.state.collectAsStateWithLifecycle() val sleepTimer by sleepTimerViewModel.state.collectAsStateWithLifecycle() val currentItem by rememberCurrentMediaItem(controller) - val context = LocalContext.current - val codecSupport = remember(context.applicationContext) { - DevicePlaybackCodecSupport(context.applicationContext) - } val stream = state.stream - val settings = state.userSettings - - LaunchedEffect( - controller, - currentItem, - stream, - state.playbackBindGeneration, - settings.defaultQuality, - settings.defaultAudioLanguage, - settings.subtitlesEnabled, - settings.defaultSubtitleLanguage, - settings.preferOriginalLanguage, - ) { - val player = controller ?: return@LaunchedEffect - val loaded = stream ?: return@LaunchedEffect - if (currentItem != null) return@LaunchedEffect - bindStreamToController( - controller = player, - stream = loaded, - videoUrl = state.videoUrl, - startMillis = state.resumeAtMillis, - selectedQuality = loaded.initialQuality(), - selectedAudioKey = loaded.initialAudioKey( - settings.defaultAudioLanguage, - settings.preferOriginalLanguage, - ), - selectedSubtitleKey = loaded.initialSubtitleKey( - settings.subtitlesEnabled, - settings.defaultSubtitleLanguage, - ), - defaultAudioLanguage = settings.defaultAudioLanguage, - automaticQualityCap = settings.defaultQuality, - preferOriginalLanguage = settings.preferOriginalLanguage, - initialPlayWhenReady = state.initialPlayWhenReady, - codecSupport = codecSupport, - prepareSabrPlayback = viewModel.sabrPlayback::prepare, - ) - player.setPlaybackSpeed(normalizeDefaultPlaybackSpeed(settings.defaultPlaybackSpeed)) - } val item = currentItem if (controller != null && item != null) { @@ -115,7 +64,6 @@ internal fun MiniPlayerRuntime( player = controller, title = branding.title, subtitle = item.mediaMetadata.artist?.toString().orEmpty(), - artworkUri = branding.thumbnailUrl, onExpand = onExpand, onSendToBackground = onSendToBackground, onClose = onClose, 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 4738eb81..d2189e4b 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 @@ -15,14 +15,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.session.MediaController import dev.typetype.android.feature.player.PlayerFullscreenEffect import dev.typetype.android.feature.player.PlayerRoute as PlayerRouteScreen import dev.typetype.android.feature.player.components.rememberIsInPipMode +import dev.typetype.android.feature.player.components.rememberAccessiblePlayerControls private val MINI_PLAYER_HEIGHT = 64.dp internal const val PLAYER_HOST_OVERLAY_TAG = "player_host_overlay" @@ -37,12 +40,15 @@ fun PlayerHost( onOpenChannel: (channelUrl: String) -> Unit, onOpenAccounts: () -> Unit, onClosePlayback: () -> Unit, + accessibleControlsEnabled: Boolean = false, onTransitionProgressChange: (Float) -> Unit = {}, content: @Composable () -> Unit, ) { val state by controller.state.collectAsStateWithLifecycle() val density = LocalDensity.current val isInPip by rememberIsInPipMode() + val accessibleControls = rememberAccessiblePlayerControls(accessibleControlsEnabled) + val hapticFeedback = LocalHapticFeedback.current val activity = LocalActivity.current val configuration = LocalConfiguration.current val orientation = when (configuration.orientation) { @@ -78,6 +84,7 @@ fun PlayerHost( activity = activity, isFullscreen = isFullscreen, locksLandscape = fullscreenOrientationState.locksLandscape, + restoresPortraitOnExit = fullscreenOrientationState.restoresPortraitOnExit, ) LaunchedEffect(orientation, hasFullscreenMedia, allowsRotationFullscreen, isFullscreen) { @@ -120,7 +127,7 @@ fun PlayerHost( miniAnchorPx = miniAnchorPx, containerHeightPx = containerHeightPx, miniHeightPx = miniHeightPx, - dragEnabled = !isFullscreen && !isInPip, + dragEnabled = !isInPip && !accessibleControls && !isFullscreen, miniContentEnabled = !isInPip, onTargetSettled = { target -> when (target) { @@ -128,12 +135,21 @@ fun PlayerHost( if (controller.state.value.target != target) controller.expand() } PlayerHostTarget.Mini -> { + if (isFullscreen) requestFullscreen(false) if (controller.state.value.target != target) controller.minimize() } else -> Unit } }, onProgressChange = onTransitionProgressChange, + onDragAnchorCrossed = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.GestureThresholdActivate, + ) + }, + onDragSettled = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, miniContent = { MiniPlayerRuntime( controller = mediaController, @@ -142,9 +158,10 @@ fun PlayerHost( onClose = onClosePlayback, ) }, - expandedContent = { transitionModifier -> + expandedContent = { transitionProgress -> PlayerRouteScreen( isFullscreen = isFullscreen, + hostTransitionProgress = transitionProgress, onFullscreenChange = requestFullscreen, onNavigateBack = { controller.collapseExpanded() }, onOpenAccounts = { @@ -152,11 +169,12 @@ fun PlayerHost( onOpenAccounts() }, onPlayVideo = { url -> controller.openVideo(url) }, + onAutoplayVideo = { url -> controller.continueWithVideo(url) }, onOpenChannel = { url -> controller.minimize() onOpenChannel(url) }, - modifier = transitionModifier, + modifier = Modifier.fillMaxSize(), ) }, ) diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt index daa2dba6..dcdc8d24 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostController.kt @@ -20,6 +20,10 @@ data class PlayerHostStateSnapshot( val initialPlayWhenReady: Boolean = true, val requestStamp: Long = 0L, val playbackClearRequestStamp: Long? = null, + val embeddedReturnTarget: PlayerHostTarget = PlayerHostTarget.Hidden, + val embeddedReturnVideoUrl: String? = null, + val embeddedReturnPositionMillis: Long? = null, + val embeddedReturnPlayWhenReady: Boolean = false, ) @Singleton @@ -40,6 +44,27 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = true, requestStamp = it.requestStamp + 1, playbackClearRequestStamp = null, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, + ) + } + playbackQueueCoordinator.clear() + } + + fun continueWithVideo(url: String) { + _state.update { + it.copy( + videoUrl = url, + resumePositionMillis = null, + initialPlayWhenReady = true, + requestStamp = it.requestStamp + 1, + playbackClearRequestStamp = null, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, ) } playbackQueueCoordinator.clear() @@ -57,6 +82,10 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = true, requestStamp = it.requestStamp + 1, playbackClearRequestStamp = null, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, ) } } @@ -73,6 +102,10 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = false, requestStamp = it.requestStamp + 1, playbackClearRequestStamp = null, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, ) } playbackQueueCoordinator.clear() @@ -91,11 +124,20 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = false, requestStamp = it.requestStamp + 1, playbackClearRequestStamp = null, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, ) } } - fun openEmbeddedVideo(url: String, autoplay: Boolean) { + fun openEmbeddedVideo( + url: String, + autoplay: Boolean, + returnPositionMillis: Long? = null, + returnPlayWhenReady: Boolean = false, + ) { require(url.isNotBlank()) val current = _state.value if ( @@ -104,6 +146,10 @@ class PlayerHostController @Inject constructor( current.initialPlayWhenReady == autoplay ) return _state.update { + val returnTarget = when (it.target) { + PlayerHostTarget.Mini, PlayerHostTarget.Expanded -> it.target + else -> PlayerHostTarget.Hidden + } it.copy( videoUrl = url, target = PlayerHostTarget.Embedded, @@ -112,13 +158,67 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = autoplay, requestStamp = it.requestStamp + 1, playbackClearRequestStamp = null, + embeddedReturnTarget = if (it.target == PlayerHostTarget.Embedded) { + it.embeddedReturnTarget + } else { + returnTarget + }, + embeddedReturnVideoUrl = if (it.target == PlayerHostTarget.Embedded) { + it.embeddedReturnVideoUrl + } else { + it.videoUrl + }, + embeddedReturnPositionMillis = if (it.target == PlayerHostTarget.Embedded) { + it.embeddedReturnPositionMillis + } else { + returnPositionMillis + }, + embeddedReturnPlayWhenReady = if (it.target == PlayerHostTarget.Embedded) { + it.embeddedReturnPlayWhenReady + } else { + returnPlayWhenReady + }, ) } - playbackQueueCoordinator.clear() } fun closeEmbeddedPlayback() { - if (_state.value.target == PlayerHostTarget.Embedded) hide() + _state.update { current -> + if (current.target != PlayerHostTarget.Embedded) { + current + } else if (current.embeddedReturnTarget == PlayerHostTarget.Hidden) { + val requestStamp = current.requestStamp + 1 + current.copy( + videoUrl = null, + target = PlayerHostTarget.Hidden, + expandedReturnTarget = PlayerHostTarget.Mini, + resumePositionMillis = null, + initialPlayWhenReady = true, + requestStamp = requestStamp, + playbackClearRequestStamp = requestStamp, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, + ) + } else { + current.copy( + videoUrl = current.embeddedReturnVideoUrl, + target = current.embeddedReturnTarget, + expandedReturnTarget = PlayerHostTarget.Mini, + resumePositionMillis = current.embeddedReturnPositionMillis, + initialPlayWhenReady = current.embeddedReturnPlayWhenReady, + requestStamp = current.requestStamp + 1, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, + ) + } + } + if (_state.value.target == PlayerHostTarget.Hidden) { + playbackQueueCoordinator.clear() + } } fun expand() { @@ -161,6 +261,10 @@ class PlayerHostController @Inject constructor( initialPlayWhenReady = true, requestStamp = requestStamp, playbackClearRequestStamp = requestStamp, + embeddedReturnTarget = PlayerHostTarget.Hidden, + embeddedReturnVideoUrl = null, + embeddedReturnPositionMillis = null, + embeddedReturnPlayWhenReady = false, ) } playbackQueueCoordinator.clear() diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt index ff9888e4..3ef5debb 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt @@ -1,28 +1,45 @@ package dev.typetype.android.feature.player.host import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.TargetedFlingBehavior import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import kotlin.math.roundToInt import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop @OptIn(ExperimentalFoundationApi::class) @Composable @@ -36,10 +53,11 @@ internal fun PlayerHostMotionLayout( miniContentEnabled: Boolean, onTargetSettled: (PlayerHostTarget) -> Unit, onProgressChange: (Float) -> Unit, + onDragAnchorCrossed: () -> Unit = {}, + onDragSettled: () -> Unit = {}, miniContent: @Composable () -> Unit, - expandedContent: @Composable (Modifier) -> Unit, + expandedContent: @Composable (() -> Float) -> Unit, ) { - val density = LocalDensity.current val anchors = remember(miniAnchorPx) { DraggableAnchors { PlayerHostTarget.Expanded at 0f @@ -49,6 +67,18 @@ internal fun PlayerHostMotionLayout( val anchoredState = remember { AnchoredDraggableState(initialValue = target.draggableTarget()) } + val transitionProgress = remember { mutableFloatStateOf(0f) } + val miniContentVisible by remember { + derivedStateOf { transitionProgress.floatValue > 0.001f } + } + val surfaceColor = MaterialTheme.colorScheme.surface + val interactionSource = remember { MutableInteractionSource() } + val isDragged by interactionSource.collectIsDraggedAsState() + var dragSettlementPending by remember { mutableStateOf(false) } + val flingBehavior = AnchoredDraggableDefaults.flingBehavior(anchoredState) + val nestedScrollConnection = remember(anchoredState, dragEnabled, flingBehavior) { + playerHostNestedScrollConnection(anchoredState, flingBehavior, dragEnabled) + } LaunchedEffect(anchors, requestStamp) { val requestedTarget = target.draggableTarget() anchoredState.updateAnchors(anchors, requestedTarget) @@ -61,47 +91,151 @@ internal fun PlayerHostMotionLayout( .distinctUntilChanged() .collect(onTargetSettled) } + LaunchedEffect(anchoredState, isDragged) { + if (!isDragged) return@LaunchedEffect + snapshotFlow { anchoredState.targetValue } + .distinctUntilChanged() + .drop(1) + .collect { onDragAnchorCrossed() } + } + LaunchedEffect(anchoredState) { + snapshotFlow { + isDragged to ( + !anchoredState.isAnimationRunning && + anchoredState.currentValue == anchoredState.settledValue && + anchoredState.targetValue == anchoredState.settledValue + ) + }.distinctUntilChanged().collect { (dragged, settled) -> + if (dragged) { + dragSettlementPending = true + } else if (dragSettlementPending && settled) { + dragSettlementPending = false + onDragSettled() + } + } + } - val transition = playerHostTransition( - offsetPx = anchoredState.offset, - miniAnchorPx = miniAnchorPx, - containerHeightPx = containerHeightPx, - miniHeightPx = miniHeightPx, - isAnimationRunning = anchoredState.isAnimationRunning, - ) - val isMini = miniContentEnabled && transition.isSettledMini - val height = with(density) { transition.heightPx.toDp() } - SideEffect { onProgressChange(transition.progress) } + LaunchedEffect(anchoredState, miniAnchorPx, containerHeightPx, miniHeightPx) { + snapshotFlow { + playerHostTransition( + offsetPx = anchoredState.offset, + miniAnchorPx = miniAnchorPx, + containerHeightPx = containerHeightPx, + miniHeightPx = miniHeightPx, + isAnimationRunning = anchoredState.isAnimationRunning, + ).progress + }.collect { progress -> + transitionProgress.floatValue = progress + onProgressChange(progress) + } + } Box( modifier = Modifier .fillMaxWidth() - .height(height) + .playerHostGeometry( + state = anchoredState, + miniAnchorPx = miniAnchorPx, + containerHeightPx = containerHeightPx, + miniHeightPx = miniHeightPx, + ) .testTag(PLAYER_HOST_OVERLAY_TAG) - .offset { IntOffset(0, transition.offsetPx) } + .nestedScroll(nestedScrollConnection) .anchoredDraggable( state = anchoredState, orientation = Orientation.Vertical, enabled = dragEnabled, + interactionSource = interactionSource, + flingBehavior = flingBehavior, ) - .background( - if (isMini) Color.Transparent - else Color.Black.copy(alpha = 1f - transition.progress), - ), + .drawBehind { + drawRect( + lerp( + start = Color.Black, + stop = surfaceColor, + fraction = transitionProgress.floatValue, + ), + ) + } ) { - if (isMini) { - miniContent() - } else { - expandedContent( - Modifier.graphicsLayer { - alpha = transition.expandedContentAlpha - clip = true + expandedContent { transitionProgress.floatValue } + if (miniContentEnabled && miniContentVisible) { + Box( + modifier = Modifier.graphicsLayer { + val progress = transitionProgress.floatValue + alpha = ((progress - 0.55f) / 0.45f).coerceIn(0f, 1f) }, - ) + ) { + miniContent() + } } } } +private fun Modifier.playerHostGeometry( + state: AnchoredDraggableState, + miniAnchorPx: Float, + containerHeightPx: Float, + miniHeightPx: Float, +): Modifier = layout { measurable, constraints -> + val transition = playerHostTransition( + offsetPx = state.offset, + miniAnchorPx = miniAnchorPx, + containerHeightPx = containerHeightPx, + miniHeightPx = miniHeightPx, + isAnimationRunning = state.isAnimationRunning, + ) + val placeable = measurable.measure( + Constraints.fixed(constraints.maxWidth, transition.heightPx), + ) + layout(placeable.width, placeable.height) { + placeable.placeRelative(IntOffset(0, transition.offsetPx)) + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun playerHostNestedScrollConnection( + state: AnchoredDraggableState, + flingBehavior: TargetedFlingBehavior, + enabled: Boolean, +): NestedScrollConnection = object : NestedScrollConnection { + private val scrollScope = object : ScrollScope { + override fun scrollBy(pixels: Float): Float = state.dispatchRawDelta(pixels) + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val offset = state.offset.takeIf(Float::isFinite) ?: 0f + if (!enabled || offset <= 0f || available.y == 0f) return Offset.Zero + return Offset(0f, state.dispatchRawDelta(available.y)) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!enabled || available.y <= 0f) return Offset.Zero + return Offset(0f, state.dispatchRawDelta(available.y)) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val offset = state.offset.takeIf(Float::isFinite) ?: 0f + if (!enabled || offset <= 0f) return Velocity.Zero + val remaining = with(flingBehavior) { scrollScope.performFling(available.y) } + state.animateTo(requireNotNull(state.anchors.closestAnchor(state.requireOffset()))) + return Velocity(0f, available.y - remaining) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val offset = state.offset.takeIf(Float::isFinite) ?: 0f + if (!enabled || offset <= 0f) return Velocity.Zero + val velocity = available.y.takeIf { it != 0f } ?: consumed.y + with(flingBehavior) { scrollScope.performFling(velocity) } + state.animateTo(requireNotNull(state.anchors.closestAnchor(state.requireOffset()))) + return Velocity(0f, available.y) + } +} + private fun PlayerHostTarget.draggableTarget(): PlayerHostTarget = when (this) { PlayerHostTarget.Embedded, PlayerHostTarget.Hidden -> PlayerHostTarget.Expanded else -> this diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostTransition.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostTransition.kt index 8313e338..ff0897e4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostTransition.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostTransition.kt @@ -6,7 +6,7 @@ internal data class PlayerHostTransition( val progress: Float, val heightPx: Int, val offsetPx: Int, - val expandedContentAlpha: Float, + val miniContentAlpha: Float, val isSettledMini: Boolean, ) @@ -28,7 +28,7 @@ internal fun playerHostTransition( progress = progress, heightPx = height.roundToInt().coerceAtLeast(1), offsetPx = safeOffset.roundToInt(), - expandedContentAlpha = (1f - progress * 0.35f).coerceIn(0f, 1f), + miniContentAlpha = ((progress - 0.55f) / 0.45f).coerceIn(0f, 1f), isSettledMini = progress >= 0.999f && !isAnimationRunning, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/queue/PlaybackQueueControls.kt b/app/src/main/java/dev/typetype/android/feature/player/queue/PlaybackQueueControls.kt index 3fd4e390..08296820 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/queue/PlaybackQueueControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/queue/PlaybackQueueControls.kt @@ -49,6 +49,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.playback.PlaybackQueueEntry import dev.typetype.android.domain.playback.PlaybackQueueState @@ -57,6 +59,7 @@ internal fun PlaybackQueueControls( state: PlaybackQueueState, viewModel: PlaybackQueueViewModel = hiltViewModel(), ) { + val serverBaseUrl = LocalServerBaseUrl.current var sheetVisible by remember { mutableStateOf(false) } if (!state.isActive) return Row( @@ -102,6 +105,7 @@ internal fun PlaybackQueueControls( if (sheetVisible) { PlaybackQueueSheet( state = state, + serverBaseUrl = serverBaseUrl, onPlay = { sheetVisible = false viewModel.play(it) @@ -120,6 +124,7 @@ internal fun PlaybackQueueControls( @Composable private fun PlaybackQueueSheet( state: PlaybackQueueState, + serverBaseUrl: String?, onPlay: (Int) -> Unit, onRetry: () -> Unit, onPlayNext: (Int) -> Unit, @@ -144,6 +149,7 @@ private fun PlaybackQueueSheet( ) { index, entry -> PlaybackQueueRow( entry = entry, + serverBaseUrl = serverBaseUrl, isCurrent = index == state.currentIndex, canPlayNext = index != state.currentIndex + 1, onClick = { onPlay(index) }, @@ -176,6 +182,7 @@ private fun PlaybackQueueSheet( @Composable private fun PlaybackQueueRow( entry: PlaybackQueueEntry, + serverBaseUrl: String?, isCurrent: Boolean, canPlayNext: Boolean, onClick: () -> Unit, @@ -207,7 +214,7 @@ private fun PlaybackQueueRow( .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize(), diff --git a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt index 35a955f7..a8a62c06 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt @@ -22,6 +22,8 @@ class PlayerGestureState { val seekHintSide: MutableState = mutableStateOf(null) val seekHintSeconds = mutableFloatStateOf(0f) val seekHintPulse: MutableLongState = mutableLongStateOf(0L) + val playbackHintPlaying: MutableState = mutableStateOf(null) + val playbackHintPulse: MutableLongState = mutableLongStateOf(0L) val brightnessOverlayActive: MutableState = mutableStateOf(false) val brightnessFraction = mutableFloatStateOf(0.5f) val volumeOverlayActive: MutableState = mutableStateOf(false) @@ -32,4 +34,15 @@ class PlayerGestureState { val seekDragTargetMs: MutableLongState = mutableLongStateOf(0L) val seekDragOverlayActive: MutableState = mutableStateOf(false) val longPressBoostActive: MutableState = mutableStateOf(false) + + fun showSeekHint(side: GestureSide, seconds: Int) { + seekHintSide.value = side + seekHintSeconds.floatValue = seconds.toFloat() + seekHintPulse.longValue += 1L + } + + fun showPlaybackHint(isPlaying: Boolean) { + playbackHintPlaying.value = isPlaying + playbackHintPulse.longValue += 1L + } } 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 b5aec884..223c52dd 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 @@ -26,6 +26,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.podcast.Podcast import java.text.NumberFormat @@ -37,13 +39,14 @@ fun PodcastHeader( onPlay: () -> Unit, onShuffle: () -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row(modifier = Modifier.fillMaxWidth()) { AsyncImage( - model = podcast.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, podcast.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.width(132.dp).aspectRatio(1f).clip(RoundedCornerShape(14.dp)), 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 4d9ffb8a..bf70ac71 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 @@ -29,6 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.search.SearchPlaylist import java.text.NumberFormat @@ -45,13 +47,14 @@ fun PublicPlaylistHeader( onShuffle: () -> Unit, onToggleSaved: () -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Row(modifier = Modifier.fillMaxWidth()) { AsyncImage( - model = playlist.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, playlist.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.width(150.dp).aspectRatio(16f / 9f).clip(RoundedCornerShape(12.dp)), diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt index 3ade1ba1..c097f22c 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistPlay import androidx.compose.material.icons.filled.Verified @@ -33,6 +32,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.search.SearchChannel import dev.typetype.android.domain.search.SearchPlaylist import java.text.NumberFormat @@ -43,6 +44,7 @@ fun SearchChannelCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current Row( modifier = modifier .fillMaxWidth() @@ -51,7 +53,7 @@ fun SearchChannelCard( verticalAlignment = Alignment.CenterVertically, ) { AsyncImage( - model = channel.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, channel.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(72.dp).clip(CircleShape), @@ -104,6 +106,7 @@ fun SearchPlaylistCard( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val serverBaseUrl = LocalServerBaseUrl.current val countLabel = displayablePlaylistStreamCount(playlist.streamCount)?.let { stringResource(R.string.search_playlist_video_count, formatCount(it)) } ?: stringResource(R.string.search_playlist_type) @@ -115,17 +118,17 @@ fun SearchPlaylistCard( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)), + .clip(MaterialTheme.shapes.medium), ) { AsyncImage( - model = playlist.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, playlist.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) Surface( modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), - shape = RoundedCornerShape(7.dp), + shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.82f), ) { Row( diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchResultsGrid.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchResultsGrid.kt index 29a7df6f..7f955853 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchResultsGrid.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchResultsGrid.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -127,7 +126,7 @@ private fun SearchSuggestionBanner( modifier = Modifier .fillMaxWidth() .then(if (isCorrected) Modifier else Modifier.clickable(onClick = onClick)), - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant, ) { Text( diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt index 5d107921..70b20f51 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -183,7 +182,7 @@ private fun SearchTopBar( modifier = Modifier .weight(1f) .height(44.dp) - .clip(RoundedCornerShape(22.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surface) .padding(start = 14.dp, end = 4.dp), ) { 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 0f7f7957..06ed4b00 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 @@ -10,7 +10,6 @@ fun SettingsRoute( onNavigateBack: () -> Unit, onOpenProfile: () -> Unit, onOpenImport: () -> Unit, - onOpenYoutubeSession: () -> Unit, onOpenRssFeeds: () -> Unit, onOpenAccounts: () -> Unit, onOpenAppearance: () -> Unit, @@ -31,8 +30,6 @@ fun SettingsRoute( onOpenProfile = onOpenProfile, onOpenImport = onOpenImport, importsAvailable = state.accountFeaturesAvailable, - onOpenYoutubeSession = onOpenYoutubeSession, - youtubeSessionAvailable = state.accountFeaturesAvailable && state.youtubeSessionAvailable, onOpenRssFeeds = onOpenRssFeeds, rssAvailable = state.accountFeaturesAvailable && state.rssAvailable, onOpenAppearance = onOpenAppearance, 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 31f9039b..45312b4d 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 @@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.ManageAccounts -import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PlayCircle @@ -52,8 +51,6 @@ fun SettingsScreen( onOpenProfile: () -> Unit = {}, onOpenImport: () -> Unit = {}, importsAvailable: Boolean = false, - onOpenYoutubeSession: () -> Unit = {}, - youtubeSessionAvailable: Boolean = false, onOpenRssFeeds: () -> Unit = {}, rssAvailable: Boolean = false, onOpenAccounts: () -> Unit = {}, @@ -73,16 +70,6 @@ fun SettingsScreen( if (importsAvailable) { add(SettingsEntry(R.string.settings_import_title, R.string.settings_import_subtitle, Icons.Filled.UploadFile, onOpenImport)) } - if (youtubeSessionAvailable) { - add( - SettingsEntry( - R.string.youtube_session_settings_title, - R.string.youtube_session_settings_subtitle, - Icons.Filled.Link, - onOpenYoutubeSession, - ), - ) - } if (rssAvailable) { add( SettingsEntry( @@ -114,7 +101,7 @@ fun SettingsScreen( contentPadding = PaddingValues(vertical = 4.dp), modifier = Modifier.weight(1f), ) { - items(entries) { entry -> + items(entries, contentType = { "settings-entry" }) { entry -> SettingsCategoryRow(entry = entry) HorizontalDivider( modifier = Modifier.padding(start = 64.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt index 74941d3a..af3b1deb 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/SettingsViewModel.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.launch data class SettingsState( val accountFeaturesAvailable: Boolean = false, val rssAvailable: Boolean = false, - val youtubeSessionAvailable: Boolean = false, ) @HiltViewModel @@ -44,8 +43,6 @@ class SettingsViewModel @Inject constructor( mutableState.update { it.copy( rssAvailable = server?.rss?.enabled == true, - youtubeSessionAvailable = - server?.youtubeRemoteLoginSupported == true, ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt index 97770fef..5872b208 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutScreen.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.settings.about -import android.content.Intent import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,57 +9,42 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.BuildConfig import dev.typetype.android.R +import dev.typetype.android.core.ui.components.SectionHeader +import dev.typetype.android.core.ui.components.KomiStoreAttribution import dev.typetype.android.feature.settings.SettingsDetailTopBar -private data class AboutLink(val labelRes: Int, val url: String) - -private val LINKS = listOf( - AboutLink(R.string.about_link_typetype, "https://github.com/TypeType-Video/TypeType"), - AboutLink(R.string.about_link_typetype_android, "https://github.com/TypeType-Video/TypeType-Android"), -) - @Composable fun AboutScreen( onNavigateBack: () -> Unit, + onOpenLicenses: () -> Unit, viewModel: AboutViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - val context = LocalContext.current Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { @@ -89,6 +73,14 @@ fun AboutScreen( } } item { Spacer(Modifier.size(8.dp)) } + item { + InfoCard { + LicenseEntry( + label = stringResource(R.string.settings_licenses_title), + onClick = onOpenLicenses, + ) + } + } item { AboutVersionsSection( versions = state.componentVersions, @@ -123,30 +115,7 @@ fun AboutScreen( } } } - item { Spacer(Modifier.size(8.dp)) } - item { SectionHeader(stringResource(R.string.about_section_links)) } - item { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - LINKS.forEach { link -> - LinkRow( - label = stringResource(link.labelRes), - url = link.url, - onClick = { - val intent = Intent(Intent.ACTION_VIEW, link.url.toUri()) - runCatching { context.startActivity(intent) } - }, - ) - } - } - } - item { - Text( - text = stringResource(R.string.about_credits), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 16.dp), - ) - } + item { KomiStoreAttribution() } } } } @@ -161,7 +130,7 @@ private fun AppHeader() { Box( modifier = Modifier .size(56.dp) - .clip(RoundedCornerShape(14.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.errorContainer), contentAlignment = Alignment.Center, ) { @@ -188,25 +157,33 @@ private fun AppHeader() { } @Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) +private fun InfoCard(content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + content() + } } @Composable -private fun InfoCard(content: @Composable ColumnScope.() -> Unit) { - Column( +private fun LicenseEntry( + label: String, + onClick: () -> Unit, +) { + Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface), + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - content() + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @Composable @@ -231,37 +208,5 @@ private fun InfoRow(label: String, value: String) { overflow = TextOverflow.Ellipsis, ) } -} - -@Composable -private fun LinkRow(label: String, url: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = url, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt index c8654742..55567f11 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/about/AboutVersionsSection.kt @@ -1,23 +1,21 @@ package dev.typetype.android.feature.settings.about -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -51,10 +49,7 @@ internal fun AboutVersionsSection( } } Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth(), ) { VersionRow(R.string.about_version_frontend, versions?.frontend, isLoading) VersionRow(R.string.about_version_server, versions?.server, isLoading) @@ -83,6 +78,7 @@ private fun VersionRow(labelRes: Int, version: ComponentVersion?, isLoading: Boo else -> VersionDetails(version) } } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @Composable diff --git a/app/src/main/java/dev/typetype/android/feature/settings/about/LibrariesProvider.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/LibrariesProvider.kt new file mode 100644 index 00000000..3786142b --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/about/LibrariesProvider.kt @@ -0,0 +1,11 @@ +package dev.typetype.android.feature.settings.about + +import android.content.Context +import com.mikepenz.aboutlibraries.Libs +import com.mikepenz.aboutlibraries.util.withContext + +internal fun buildLibraries(context: Context): Libs { + return Libs.Builder() + .withContext(context) + .build() +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/about/LicensesScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/about/LicensesScreen.kt new file mode 100644 index 00000000..3193caf8 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/about/LicensesScreen.kt @@ -0,0 +1,37 @@ +package dev.typetype.android.feature.settings.about + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer +import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryDetailMode +import dev.typetype.android.R +import dev.typetype.android.feature.settings.SettingsDetailTopBar + +@Composable +fun LicensesScreen( + onNavigateBack: () -> Unit, +) { + val context = LocalContext.current + val libraries = remember { buildLibraries(context) } + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + SettingsDetailTopBar( + title = stringResource(R.string.settings_licenses_title), + onNavigateBack = onNavigateBack, + ) + LibrariesContainer( + libraries = libraries, + modifier = Modifier.fillMaxSize(), + detailMode = LibraryDetailMode.Sheet, + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/accounts/AccountSettingsCards.kt b/app/src/main/java/dev/typetype/android/feature/settings/accounts/AccountSettingsCards.kt index 806c5c5e..16895727 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/accounts/AccountSettingsCards.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/accounts/AccountSettingsCards.kt @@ -1,7 +1,7 @@ package dev.typetype.android.feature.settings.accounts -import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.DeleteOutline @@ -24,8 +23,8 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -51,9 +50,7 @@ fun InstanceAccountsCard( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + .padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( @@ -118,11 +115,7 @@ private fun AccountRow( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background( - if (selected) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - ) + .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent) .clickable(enabled = !selected && !busy, onClick = onSelect) .padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceAction.kt new file mode 100644 index 00000000..243c5953 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceAction.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.feature.settings.appearance + +import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceFont +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearanceMotion +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.MangaHeadlineMarker +import dev.typetype.android.domain.preferences.MangaPaper + +sealed interface AppearanceAction { + data class SelectAccent(val accent: AccentColor) : AppearanceAction + data class SelectPersonality(val personality: AppearancePersonality) : AppearanceAction + data class SelectMode(val mode: AppearanceMode) : AppearanceAction + data class SetAmoled(val enabled: Boolean) : AppearanceAction + data class SelectTheme(val theme: AppearanceTheme) : AppearanceAction + data class SelectFont(val font: AppearanceFont) : AppearanceAction + data class SelectMotion(val motion: AppearanceMotion) : AppearanceAction + data class SelectMangaPaper(val paper: MangaPaper) : AppearanceAction + data class SelectHeadlineMarker(val marker: MangaHeadlineMarker) : AppearanceAction + data class SetMangaDecoration( + val decoration: MangaDecoration, + val enabled: Boolean, + ) : AppearanceAction +} + +enum class MangaDecoration { + Screentone, + SpeedLines, + Starburst, + InkedIcons, + PanelTilt, +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceScreen.kt index ceac68aa..449fb0d9 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceScreen.kt @@ -1,64 +1,27 @@ package dev.typetype.android.feature.settings.appearance -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R import dev.typetype.android.core.ui.components.SectionHeader -import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.core.ui.components.KomiStoreAttribution +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppPreferences import dev.typetype.android.feature.settings.SettingsDetailTopBar -private data class AccentSwatch(val accent: AccentColor, val labelRes: Int, val color: Color) - -private val swatches = listOf( - AccentSwatch(AccentColor.Red, R.string.accent_red, Color(0xFFEF4444)), - AccentSwatch(AccentColor.Blue, R.string.accent_blue, Color(0xFF60A5FA)), - AccentSwatch(AccentColor.Yellow, R.string.accent_yellow, Color(0xFFFBBF24)), - AccentSwatch(AccentColor.Green, R.string.accent_green, Color(0xFF34D399)), - AccentSwatch(AccentColor.Purple, R.string.accent_purple, Color(0xFFC084FC)), - AccentSwatch(AccentColor.Violet, R.string.accent_violet, Color(0xFFA78BFA)), - AccentSwatch(AccentColor.Monochrome, R.string.accent_monochrome, Color(0xFFE4E4E7)), -) - @Composable fun AppearanceRoute( onNavigateBack: () -> Unit, @@ -66,91 +29,60 @@ fun AppearanceRoute( ) { val state by viewModel.state.collectAsStateWithLifecycle() AppearanceScreen( - currentAccent = state.accentColor, - onAccentSelected = viewModel::selectAccent, + state = state, + onAction = viewModel::onAction, onNavigateBack = onNavigateBack, ) } @Composable fun AppearanceScreen( - currentAccent: AccentColor, - onAccentSelected: (AccentColor) -> Unit, + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, onNavigateBack: () -> Unit, ) { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column(modifier = Modifier.fillMaxSize()) { - SettingsDetailTopBar( - title = stringResource(R.string.settings_appearance_title), - onNavigateBack = onNavigateBack, - ) - Spacer(Modifier.height(16.dp)) - SectionHeader( - text = stringResource(R.string.settings_appearance_accent_color), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - Spacer(Modifier.height(8.dp)) - LazyVerticalGrid( - columns = GridCells.Fixed(4), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - modifier = Modifier.fillMaxWidth(), - ) { - items(swatches, key = { it.accent.name }) { swatch -> - AccentSwatchCell( - swatch = swatch, - isSelected = swatch.accent == currentAccent, - onClick = { onAccentSelected(swatch.accent) }, - ) - } + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + item { + SettingsDetailTopBar( + title = stringResource(R.string.settings_appearance_title), + onNavigateBack = onNavigateBack, + ) + } + item { AppearancePreview(state, Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) } + appearanceSection(R.string.appearance_style) + appearancePersonalityItems(state, onAction) + appearanceSection(R.string.appearance_theme) + appearanceThemeItems(state, onAction) + if (state.appearancePersonality == AppearancePersonality.Manga) { + appearanceSection(R.string.appearance_manga_paper) + mangaPaperItems(state, onAction) + appearanceSection(R.string.appearance_manga_details) + mangaDetailItems(state, onAction) } + appearanceSection(R.string.appearance_mode) + appearanceModeItems(state, onAction) + if (state.appearanceMode != AppearanceMode.Light) appearanceAmoledItem(state, onAction) + appearanceSection(R.string.settings_appearance_accent_color) + item { AccentChooser(state.accentColor, onAction) } + appearanceSection(R.string.appearance_typography) + appearanceFontItems(state, onAction) + appearanceSection(R.string.appearance_motion) + appearanceMotionItems(state, onAction) + item { KomiStoreAttribution() } } } } -@Composable -private fun AccentSwatchCell( - swatch: AccentSwatch, - isSelected: Boolean, - onClick: () -> Unit, -) { - Column( - modifier = Modifier.selectable( - selected = isSelected, - role = Role.RadioButton, - onClick = onClick, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(swatch.color) - .border( - width = if (isSelected) 3.dp else 0.dp, - color = MaterialTheme.colorScheme.onBackground, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Icon( - imageVector = Icons.Filled.Check, - contentDescription = null, - tint = Color.Black, - ) - } - } - Spacer(Modifier.height(6.dp)) - Text( - text = stringResource(swatch.labelRes), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, +private fun androidx.compose.foundation.lazy.LazyListScope.appearanceSection(title: Int) { + item(key = "section-$title") { + SectionHeader( + text = stringResource(title), + modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp), ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceSettingsSections.kt b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceSettingsSections.kt new file mode 100644 index 00000000..a2289ba1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceSettingsSections.kt @@ -0,0 +1,280 @@ +package dev.typetype.android.feature.settings.appearance + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.TypeTypeSwitch +import dev.typetype.android.domain.preferences.AccentColor +import dev.typetype.android.domain.preferences.AppearanceFont +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearanceMotion +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.AppPreferences +import dev.typetype.android.domain.preferences.MangaHeadlineMarker +import dev.typetype.android.domain.preferences.MangaPaper + +private data class LabelledOption(val value: T, val title: Int, val subtitle: Int? = null) +private data class AccentSwatch(val accent: AccentColor, val label: Int, val color: Color) + +private val personalityOptions = listOf( + LabelledOption(AppearancePersonality.Classic, R.string.appearance_classic, R.string.appearance_classic_description), + LabelledOption(AppearancePersonality.Manga, R.string.appearance_manga, R.string.appearance_manga_description), +) +private val modeOptions = listOf( + LabelledOption(AppearanceMode.System, R.string.appearance_system), + LabelledOption(AppearanceMode.Light, R.string.appearance_light), + LabelledOption(AppearanceMode.Dark, R.string.appearance_dark), +) +private val themeOptions = listOf( + LabelledOption(AppearanceTheme.TypeType, R.string.appearance_theme_typetype, R.string.appearance_theme_typetype_description), + LabelledOption(AppearanceTheme.Dynamic, R.string.appearance_theme_dynamic, R.string.appearance_theme_dynamic_description), + LabelledOption(AppearanceTheme.Nord, R.string.appearance_theme_nord), + LabelledOption(AppearanceTheme.Cream, R.string.appearance_theme_cream), + LabelledOption(AppearanceTheme.Forest, R.string.appearance_theme_forest), + LabelledOption(AppearanceTheme.Plum, R.string.appearance_theme_plum), +) +private val paperOptions = listOf( + LabelledOption(MangaPaper.Day, R.string.appearance_paper_day), + LabelledOption(MangaPaper.Night, R.string.appearance_paper_night), + LabelledOption(MangaPaper.Nord, R.string.appearance_paper_nord), +) +private val fontOptions = listOf( + LabelledOption(AppearanceFont.System, R.string.appearance_font_system, R.string.appearance_font_system_description), + LabelledOption(AppearanceFont.Expressive, R.string.appearance_font_expressive, R.string.appearance_font_expressive_description), +) +private val motionOptions = listOf( + LabelledOption(AppearanceMotion.Full, R.string.appearance_motion_full), + LabelledOption(AppearanceMotion.Subtle, R.string.appearance_motion_subtle), + LabelledOption(AppearanceMotion.Off, R.string.appearance_motion_off), +) +private val accents = listOf( + AccentSwatch(AccentColor.System, R.string.accent_system, Color(0xFF8AB4F8)), + AccentSwatch(AccentColor.Red, R.string.accent_red, Color(0xFFEF4444)), + AccentSwatch(AccentColor.Blue, R.string.accent_blue, Color(0xFF60A5FA)), + AccentSwatch(AccentColor.Yellow, R.string.accent_yellow, Color(0xFFFBBF24)), + AccentSwatch(AccentColor.Green, R.string.accent_green, Color(0xFF34D399)), + AccentSwatch(AccentColor.Purple, R.string.accent_purple, Color(0xFFC084FC)), + AccentSwatch(AccentColor.Violet, R.string.accent_violet, Color(0xFFA78BFA)), + AccentSwatch(AccentColor.Monochrome, R.string.accent_monochrome, Color(0xFFE4E4E7)), +) + +fun androidx.compose.foundation.lazy.LazyListScope.appearancePersonalityItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(personalityOptions, state.appearancePersonality) { + onAction(AppearanceAction.SelectPersonality(it)) +} + +fun androidx.compose.foundation.lazy.LazyListScope.appearanceModeItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(modeOptions, state.appearanceMode) { onAction(AppearanceAction.SelectMode(it)) } + +fun androidx.compose.foundation.lazy.LazyListScope.appearanceThemeItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(themeOptions, state.appearanceTheme) { onAction(AppearanceAction.SelectTheme(it)) } + +fun androidx.compose.foundation.lazy.LazyListScope.mangaPaperItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(paperOptions, state.mangaPaper) { onAction(AppearanceAction.SelectMangaPaper(it)) } + +fun androidx.compose.foundation.lazy.LazyListScope.appearanceFontItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(fontOptions, state.appearanceFont) { onAction(AppearanceAction.SelectFont(it)) } + +fun androidx.compose.foundation.lazy.LazyListScope.appearanceMotionItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = options(motionOptions, state.appearanceMotion) { onAction(AppearanceAction.SelectMotion(it)) } + +private fun androidx.compose.foundation.lazy.LazyListScope.options( + entries: List>, + selected: T, + onSelect: (T) -> Unit, +) { + items(entries.size, contentType = { "appearance-option" }) { index -> + val option = entries[index] + AppearanceOptionRow(option, option.value == selected) { onSelect(option.value) } + } +} + +@Composable +private fun AppearanceOptionRow(option: LabelledOption, selected: Boolean, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().selectable( + selected = selected, + role = Role.RadioButton, + onClick = onClick, + ).padding(20.dp, 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + RadioButton(selected = selected, onClick = null) + Column(Modifier.weight(1f)) { + Text(stringResource(option.title), style = MaterialTheme.typography.bodyLarge) + option.subtitle?.let { + Text(stringResource(it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + +fun androidx.compose.foundation.lazy.LazyListScope.appearanceAmoledItem( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) = item { + AppearanceToggleRow( + title = R.string.appearance_amoled, + subtitle = R.string.appearance_amoled_description, + checked = state.appearanceAmoled, + onCheckedChange = { onAction(AppearanceAction.SetAmoled(it)) }, + ) +} + +fun androidx.compose.foundation.lazy.LazyListScope.mangaDetailItems( + state: AppPreferences, + onAction: (AppearanceAction) -> Unit, +) { + val markers = listOf( + LabelledOption(MangaHeadlineMarker.None, R.string.appearance_marker_none), + LabelledOption(MangaHeadlineMarker.Stamp, R.string.appearance_marker_stamp), + LabelledOption(MangaHeadlineMarker.SpeedLines, R.string.appearance_marker_speed_lines), + ) + options(markers, state.mangaHeadlineMarker) { onAction(AppearanceAction.SelectHeadlineMarker(it)) } + mangaToggle(R.string.appearance_screentone, MangaDecoration.Screentone, state.mangaScreentone, onAction) + mangaToggle(R.string.appearance_speed_lines, MangaDecoration.SpeedLines, state.mangaSpeedLines, onAction) + mangaToggle(R.string.appearance_starburst, MangaDecoration.Starburst, state.mangaStarburst, onAction) + mangaToggle(R.string.appearance_inked_icons, MangaDecoration.InkedIcons, state.mangaInkedIcons, onAction) + mangaToggle(R.string.appearance_panel_tilt, MangaDecoration.PanelTilt, state.mangaPanelTilt, onAction) +} + +private fun androidx.compose.foundation.lazy.LazyListScope.mangaToggle( + title: Int, + decoration: MangaDecoration, + checked: Boolean, + onAction: (AppearanceAction) -> Unit, +) = item { + AppearanceToggleRow(title, null, checked) { + onAction(AppearanceAction.SetMangaDecoration(decoration, it)) + } +} + +@Composable +private fun AppearanceToggleRow( + title: Int, + subtitle: Int?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onCheckedChange(!checked) }.padding(20.dp, 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(title), style = MaterialTheme.typography.bodyLarge) + subtitle?.let { Text(stringResource(it), style = MaterialTheme.typography.bodySmall) } + } + TypeTypeSwitch(checked = checked, onCheckedChange = onCheckedChange) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + +@Composable +fun AccentChooser(current: AccentColor, onAction: (AppearanceAction) -> Unit) { + Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 20.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + accents.forEach { swatch -> + val label = stringResource(swatch.label) + Column( + modifier = Modifier.selectable( + selected = swatch.accent == current, + role = Role.RadioButton, + ) { onAction(AppearanceAction.SelectAccent(swatch.accent)) }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.size(48.dp).background(swatch.color, CircleShape) + .border(if (swatch.accent == current) 3.dp else 1.dp, MaterialTheme.colorScheme.onBackground, CircleShape), + ) + Text(label, style = MaterialTheme.typography.labelSmall) + } + } + } +} + +@Composable +fun AppearancePreview(state: AppPreferences, modifier: Modifier = Modifier) { + val manga = state.appearancePersonality == AppearancePersonality.Manga + Surface( + modifier = modifier.fillMaxWidth().rotate(if (manga && state.mangaPanelTilt) -1f else 0f), + border = if (manga) BorderStroke(2.dp, MaterialTheme.colorScheme.outline) else null, + tonalElevation = if (manga) 0.dp else 2.dp, + ) { + Box(Modifier.fillMaxWidth().height(132.dp)) { + if (manga && state.mangaScreentone) MangaPreviewTexture() + Column(Modifier.align(Alignment.CenterStart).padding(20.dp)) { + Text( + text = stringResource(if (manga) R.string.appearance_preview_manga else R.string.appearance_preview_classic), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text(stringResource(R.string.appearance_preview_body), style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun MangaPreviewTexture() { + val color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.09f) + Canvas(Modifier.fillMaxWidth().height(132.dp)) { + var x = 4.dp.toPx() + val step = 9.dp.toPx() + while (x < size.width) { + var y = 4.dp.toPx() + while (y < size.height) { + drawCircle(color, 1.dp.toPx(), Offset(x, y)) + y += step + } + x += step + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceViewModel.kt index 9c89dc75..80e5f992 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/appearance/AppearanceViewModel.kt @@ -3,7 +3,6 @@ package dev.typetype.android.feature.settings.appearance import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.typetype.android.domain.preferences.AccentColor import dev.typetype.android.domain.preferences.AppPreferences import dev.typetype.android.domain.preferences.PreferencesRepository import javax.inject.Inject @@ -24,9 +23,32 @@ class AppearanceViewModel @Inject constructor( initialValue = AppPreferences(), ) - fun selectAccent(accent: AccentColor) { + fun onAction(action: AppearanceAction) { viewModelScope.launch { - preferencesRepository.setAccentColor(accent) + when (action) { + is AppearanceAction.SelectAccent -> preferencesRepository.setAccentColor(action.accent) + is AppearanceAction.SelectPersonality -> + preferencesRepository.setAppearancePersonality(action.personality) + is AppearanceAction.SelectMode -> preferencesRepository.setAppearanceMode(action.mode) + is AppearanceAction.SetAmoled -> preferencesRepository.setAppearanceAmoled(action.enabled) + is AppearanceAction.SelectTheme -> preferencesRepository.setAppearanceTheme(action.theme) + is AppearanceAction.SelectFont -> preferencesRepository.setAppearanceFont(action.font) + is AppearanceAction.SelectMotion -> preferencesRepository.setAppearanceMotion(action.motion) + is AppearanceAction.SelectMangaPaper -> preferencesRepository.setMangaPaper(action.paper) + is AppearanceAction.SelectHeadlineMarker -> + preferencesRepository.setMangaHeadlineMarker(action.marker) + is AppearanceAction.SetMangaDecoration -> setMangaDecoration(action) + } + } + } + + private suspend fun setMangaDecoration(action: AppearanceAction.SetMangaDecoration) { + when (action.decoration) { + MangaDecoration.Screentone -> preferencesRepository.setMangaScreentone(action.enabled) + MangaDecoration.SpeedLines -> preferencesRepository.setMangaSpeedLines(action.enabled) + MangaDecoration.Starburst -> preferencesRepository.setMangaStarburst(action.enabled) + MangaDecoration.InkedIcons -> preferencesRepository.setMangaInkedIcons(action.enabled) + MangaDecoration.PanelTilt -> preferencesRepository.setMangaPanelTilt(action.enabled) } } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPath.kt b/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPath.kt new file mode 100644 index 00000000..545aba40 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPath.kt @@ -0,0 +1,27 @@ +package dev.typetype.android.feature.settings.blocked + +import java.net.URI + +internal fun blockedItemDisplayPath(value: String): String { + val trimmed = value.trim() + if (trimmed.isEmpty()) return "/" + if (trimmed.startsWith('/')) return trimmed + + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return "/$trimmed" + val host = uri.host.orEmpty().lowercase() + val path = uri.rawPath.orEmpty() + val videoId = when { + host == "youtu.be" || host.endsWith(".youtu.be") -> + path.trim('/').substringBefore('/').takeIf(String::isNotBlank) + host == "youtube.com" || host.endsWith(".youtube.com") -> + uri.rawQuery.orEmpty().split('&') + .firstOrNull { it.substringBefore('=') == "v" } + ?.substringAfter('=', "") + ?.takeIf(String::isNotBlank) + else -> null + } + if (videoId != null) return "/$videoId" + + val visiblePath = path.ifBlank { "/" } + return uri.rawQuery?.takeIf(String::isNotBlank)?.let { "$visiblePath?$it" } ?: visiblePath +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedSettingsScreen.kt index c751b337..f1ea6dba 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/blocked/BlockedSettingsScreen.kt @@ -2,6 +2,7 @@ package dev.typetype.android.feature.settings.blocked import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -17,20 +18,27 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TextButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,9 +52,13 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.components.SectionHeader +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.actions.BlockedItem import dev.typetype.android.domain.actions.BlockedKeyword import dev.typetype.android.feature.settings.SettingsDetailTopBar +import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun BlockedSettingsRoute( @@ -54,6 +66,31 @@ fun BlockedSettingsRoute( viewModel: BlockedSettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() + val serverBaseUrl = LocalServerBaseUrl.current + var channelLimit by remember { mutableIntStateOf(BLOCKED_PAGE_SIZE) } + var videoLimit by remember { mutableIntStateOf(BLOCKED_PAGE_SIZE) } + val visibleChannels = state.channels.take(channelLimit) + val visibleVideos = state.videos.take(videoLimit) + val listState = rememberLazyListState() + + LaunchedEffect(listState, state.channels, state.videos) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val lastIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastIndex >= layoutInfo.totalItemsCount - 2 + }.distinctUntilChanged().collect { isNearEnd -> + if (!isNearEnd) return@collect + + channelLimit = minOf( + channelLimit + BLOCKED_PAGE_SIZE, + maxOf(state.channels.size, BLOCKED_PAGE_SIZE), + ) + videoLimit = minOf( + videoLimit + BLOCKED_PAGE_SIZE, + maxOf(state.videos.size, BLOCKED_PAGE_SIZE), + ) + } + } Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { SettingsDetailTopBar( @@ -61,6 +98,7 @@ fun BlockedSettingsRoute( onNavigateBack = onNavigateBack, ) LazyColumn( + state = listState, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -77,7 +115,11 @@ fun BlockedSettingsRoute( if (state.keywords.isEmpty()) { item { EmptyRow(stringResource(R.string.settings_blocked_empty_keywords)) } } else { - items(state.keywords, key = { "keyword-${it.keyword}" }) { item -> + items( + state.keywords, + key = { "keyword-${it.keyword}" }, + contentType = { "blocked-keyword" }, + ) { item -> BlockedKeywordRow(item, onUnblock = { viewModel.unblockKeyword(item.keyword) }) } } @@ -86,26 +128,46 @@ fun BlockedSettingsRoute( if (state.channels.isEmpty()) { item { EmptyRow(stringResource(R.string.settings_blocked_empty_channels)) } } else { - items(state.channels, key = { "ch-${it.url}" }) { item -> + items( + state.channels, + key = { "ch-${it.url}" }, + contentType = { "blocked-channel" }, + ) { item -> BlockedRow( item = item, + serverBaseUrl = serverBaseUrl, avatarShape = CircleShape, onUnblock = { viewModel.unblockChannel(item.url) }, ) } + if (state.channels.size > channelLimit) { + item(key = "load-more-channels") { + Box(modifier = Modifier.height(BLOCKED_LOAD_SENTINEL_DP)) {} + } + } } item { Spacer(Modifier.size(8.dp)) } item { SectionHeader(stringResource(R.string.settings_blocked_section_videos)) } if (state.videos.isEmpty()) { item { EmptyRow(stringResource(R.string.settings_blocked_empty_videos)) } } else { - items(state.videos, key = { "vid-${it.url}" }) { item -> + items( + state.videos, + key = { "vid-${it.url}" }, + contentType = { "blocked-video" }, + ) { item -> BlockedRow( item = item, - avatarShape = RoundedCornerShape(8.dp), + serverBaseUrl = serverBaseUrl, + avatarShape = MaterialTheme.shapes.small, onUnblock = { viewModel.unblockVideo(item.url) }, ) } + if (state.videos.size > videoLimit) { + item(key = "load-more-videos") { + Box(modifier = Modifier.height(BLOCKED_LOAD_SENTINEL_DP)) {} + } + } } } } @@ -143,48 +205,40 @@ private fun KeywordInput( @Composable private fun BlockedKeywordRow(item: BlockedKeyword, onUnblock: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = item.keyword, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - TextButton(onClick = onUnblock) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Text( - text = stringResource(R.string.settings_blocked_unblock), - color = MaterialTheme.colorScheme.error, + text = item.keyword, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + TextButton(onClick = onUnblock) { + Text( + text = stringResource(R.string.settings_blocked_unblock), + color = MaterialTheme.colorScheme.error, + ) + } } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } -@Composable -private fun SectionHeader(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - ) -} +private const val BLOCKED_PAGE_SIZE = 50 +private val BLOCKED_LOAD_SENTINEL_DP = 48.dp @Composable private fun EmptyRow(text: String) { Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 14.dp), contentAlignment = Alignment.CenterStart, ) { @@ -199,57 +253,59 @@ private fun EmptyRow(text: String) { @Composable private fun BlockedRow( item: BlockedItem, + serverBaseUrl: String?, avatarShape: androidx.compose.ui.graphics.Shape, onUnblock: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( + Column { + Row( modifier = Modifier - .size(40.dp) - .clip(avatarShape) - .background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, ) { - if (item.thumbnailUrl.isNotBlank()) { - AsyncImage( - model = item.thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + Box( + modifier = Modifier + .size(40.dp) + .clip(avatarShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (item.thumbnailUrl.isNotBlank()) { + AsyncImage( + model = buildImageUrl(serverBaseUrl, item.thumbnailUrl), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } } - } - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = item.name.ifBlank { item.url }, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (item.name.isNotBlank()) { + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = item.url, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = item.name.ifBlank { blockedItemDisplayPath(item.url) }, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (item.name.isNotBlank()) { + Text( + text = blockedItemDisplayPath(item.url), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + TextButton(onClick = onUnblock) { + Text( + text = stringResource(R.string.settings_blocked_unblock), + color = MaterialTheme.colorScheme.error, + ) } } - TextButton(onClick = onUnblock) { - Text( - text = stringResource(R.string.settings_blocked_unblock), - color = MaterialTheme.colorScheme.error, - ) - } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt index c759dc58..5abb6298 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsAction.kt @@ -9,7 +9,6 @@ sealed interface ContentSettingsAction { sealed interface Update : ContentSettingsAction data class SetDefaultLandingPage(val page: String) : Update - data class SetAllHidden(val hidden: Boolean) : Update data class SetHideHomeRecommendations(val hidden: Boolean) : Update data class SetHideContinueWatching(val hidden: Boolean) : Update data class SetHideRelatedVideos(val hidden: Boolean) : Update @@ -25,18 +24,6 @@ sealed interface ContentSettingsAction { internal fun UserSettings.updatedBy(action: ContentSettingsAction.Update): UserSettings = when (action) { is ContentSettingsAction.SetDefaultLandingPage -> copy(defaultLandingPage = action.page) - is ContentSettingsAction.SetAllHidden -> copy( - hideHomeRecommendations = action.hidden, - hideContinueWatching = action.hidden, - hideRelatedVideos = action.hidden, - hideComments = action.hidden, - hideShorts = action.hidden, - hideSubscriptionLiveStreams = if (supportsHideSubscriptionLiveStreams) { - action.hidden - } else { - hideSubscriptionLiveStreams - }, - ) is ContentSettingsAction.SetHideHomeRecommendations -> copy(hideHomeRecommendations = action.hidden) is ContentSettingsAction.SetHideContinueWatching -> diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsFailureCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsFailureCard.kt index 4552d65c..141a3d7d 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsFailureCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsFailureCard.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Icon @@ -33,7 +32,7 @@ internal fun ContentSettingsFailureCard( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(horizontal = 16.dp, vertical = 12.dp), ) { diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt index 66135347..a08f8570 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsScreen.kt @@ -21,9 +21,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -58,16 +55,6 @@ fun ContentSettingsScreen( onNavigateBack: () -> Unit, ) { val controlsEnabled = !state.isLoading && !state.isUpdating - var confirmHideEverything by rememberSaveable { mutableStateOf(false) } - if (confirmHideEverything) { - HideEverythingConfirmation( - onConfirm = { - confirmHideEverything = false - onAction(ContentSettingsAction.SetAllHidden(true)) - }, - onDismiss = { confirmHideEverything = false }, - ) - } Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { SettingsDetailTopBar( @@ -121,21 +108,6 @@ fun ContentSettingsScreen( deArrowRows(state, controlsEnabled, onAction) } item { SettingsSectionHeader(stringResource(R.string.settings_content_visibility)) } - item { - SwitchRow( - title = stringResource(R.string.settings_content_hide_everything), - subtitle = stringResource(R.string.settings_content_hide_everything_subtitle), - checked = state.areAllSurfacesHidden(), - onCheckedChange = { hidden -> - if (hidden) { - confirmHideEverything = true - } else { - onAction(ContentSettingsAction.SetAllHidden(false)) - } - }, - enabled = controlsEnabled, - ) - } item { ContentVisibilitySwitch( title = R.string.settings_content_hide_continue, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt index 655e3f30..9e0fe6ca 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsState.kt @@ -18,7 +18,3 @@ data class ContentSettingsState( val errorMessage: String? = null, val errorRequestId: String? = null, ) - -internal fun ContentSettingsState.areAllSurfacesHidden(): Boolean = - hideHomeRecommendations && hideContinueWatching && hideRelatedVideos && hideComments && - hideShorts && (!supportsHideSubscriptionLiveStreams || hideSubscriptionLiveStreams) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt index 07609598..26da36f0 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/content/ContentSettingsViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.typetype.android.R import dev.typetype.android.core.ui.error.UserErrorMapper +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.preferences.StartupLandingStore import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject @@ -20,6 +22,8 @@ import kotlinx.coroutines.launch class ContentSettingsViewModel @Inject constructor( private val repository: UserSettingsRepository, private val errorMapper: UserErrorMapper, + private val activeAccountScope: ActiveAccountScope, + private val startupLandingStore: StartupLandingStore, ) : ViewModel() { private val feedback = MutableStateFlow(ContentSettingsFeedback()) @@ -62,7 +66,13 @@ class ContentSettingsViewModel @Inject constructor( viewModelScope.launch { feedback.update { it.copy(isUpdating = true, errorMessage = null, errorRequestId = null) } repository.update { it.updatedBy(action) }.fold( - onSuccess = { feedback.update { it.copy(isUpdating = false) } }, + onSuccess = { + if (action is ContentSettingsAction.SetDefaultLandingPage) { + val scope = activeAccountScope.require() + startupLandingStore.setLandingPage(scope, action.page) + } + feedback.update { it.copy(isUpdating = false) } + }, onFailure = { failure -> showFailure(failure, isUpdating = false) }, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/content/HideEverythingConfirmation.kt b/app/src/main/java/dev/typetype/android/feature/settings/content/HideEverythingConfirmation.kt deleted file mode 100644 index ad9f6a2f..00000000 --- a/app/src/main/java/dev/typetype/android/feature/settings/content/HideEverythingConfirmation.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.typetype.android.feature.settings.content - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import dev.typetype.android.R - -@Composable -internal fun HideEverythingConfirmation( - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.settings_content_hide_everything_confirm_title)) }, - text = { Text(stringResource(R.string.settings_content_hide_everything_confirm_message)) }, - confirmButton = { - TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_confirm)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - }, - ) -} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashDiagnosticCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashDiagnosticCard.kt index a9f7eca9..fa1440a4 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashDiagnosticCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashDiagnosticCard.kt @@ -4,7 +4,6 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -24,7 +23,7 @@ import java.util.Date internal fun CrashDiagnosticCard(report: CrashReport) { Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(14.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface, ) { Column( diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashReportScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashReportScreen.kt index 8f2aba95..74fe9dca 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashReportScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/CrashReportScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api @@ -196,7 +195,7 @@ private fun CrashSection( ) { Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface, ) { Column( diff --git a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt index 846e06fd..371184c3 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/diagnostics/DiagnosticsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Button @@ -202,7 +201,7 @@ private fun DiagnosticRow(entry: DiagnosticEntry) { Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(3.dp), ) { @@ -279,7 +278,7 @@ private fun ApplicationDiagnosticRow(entry: DiagnosticEntry, formatter: DateForm Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.large) .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(3.dp), ) { diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/ImportDataScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/ImportDataScreen.kt index d85bfe65..9663e424 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/imports/ImportDataScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/ImportDataScreen.kt @@ -4,15 +4,17 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon @@ -23,8 +25,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -37,6 +41,7 @@ import dev.typetype.android.feature.settings.SettingsDetailTopBar import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.launch @Composable fun ImportDataRoute( @@ -123,45 +128,96 @@ fun ImportDataScreen( onRemoveYoutubeTakeout: (String) -> Unit, onRetryYoutubeRefresh: () -> Unit, ) { + val errorMessage = importErrorMessage(state.errorKey) + val listState = remember { LazyListState() } + val scope = rememberCoroutineScope() Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Column(modifier = Modifier.fillMaxSize()) { SettingsDetailTopBar( title = stringResource(R.string.settings_import_title), onNavigateBack = onNavigateBack, ) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - TypeTypeBackupSection( - state = state, - onToggleCategory = onToggleCategory, - onExport = onExportTypeType, - onChooseBackup = onChooseTypeTypeBackup, - onResetResult = onResetTypeTypeResult, - ) - YoutubeTakeoutImportSection( - state = youtubeState, - onOpenTakeout = onOpenYoutubeTakeout, - onChooseArchives = onChooseYoutubeTakeout, - onRetry = onRetryYoutubeTakeout, - onCancel = onCancelYoutubeTakeout, - onRemove = onRemoveYoutubeTakeout, - onRetryCollectionRefresh = onRetryYoutubeRefresh, - ) - PipePipeImportSection( - state = state, - onChooseBackup = onChoosePipePipeBackup, - onRestore = onRestorePipePipe, - onResetResult = onResetPipePipeResult, - ) - importErrorMessage(state.errorKey)?.let { - Text(it, color = MaterialTheme.colorScheme.error) + item { + Text( + text = stringResource(R.string.settings_import_portability_title), + style = MaterialTheme.typography.headlineSmall, + ) + } + item { + Text( + text = stringResource(R.string.settings_import_portability_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ImportSourceCard( + title = stringResource(R.string.settings_backup_typetype_title), + subtitle = stringResource(R.string.settings_import_source_backup), + modifier = Modifier.weight(1f), + onClick = { + scope.launch { listState.animateScrollToItem(TYPE_TYPE_SECTION_INDEX) } + }, + ) + ImportSourceCard( + title = stringResource(R.string.settings_import_youtube_title), + subtitle = stringResource(R.string.settings_import_source_youtube), + modifier = Modifier.weight(1f), + onClick = { + scope.launch { listState.animateScrollToItem(YOUTUBE_SECTION_INDEX) } + }, + ) + ImportSourceCard( + title = stringResource(R.string.settings_import_pipepipe_title), + subtitle = stringResource(R.string.settings_import_source_pipepipe), + modifier = Modifier.weight(1f), + onClick = { + scope.launch { listState.animateScrollToItem(PIPEPIPE_SECTION_INDEX) } + }, + ) + } + } + item { + TypeTypeBackupSection( + state = state, + onToggleCategory = onToggleCategory, + onExport = onExportTypeType, + onChooseBackup = onChooseTypeTypeBackup, + onResetResult = onResetTypeTypeResult, + ) + } + item { + YoutubeTakeoutImportSection( + state = youtubeState, + onOpenTakeout = onOpenYoutubeTakeout, + onChooseArchives = onChooseYoutubeTakeout, + onRetry = onRetryYoutubeTakeout, + onCancel = onCancelYoutubeTakeout, + onRemove = onRemoveYoutubeTakeout, + onRetryCollectionRefresh = onRetryYoutubeRefresh, + ) + } + item { + PipePipeImportSection( + state = state, + onChooseBackup = onChoosePipePipeBackup, + onRestore = onRestorePipePipe, + onResetResult = onResetPipePipeResult, + ) + } + errorMessage?.let { message -> + item { Text(message, color = MaterialTheme.colorScheme.error) } + } + state.errorRequestId?.let { requestId -> + item { RequestIdRow(requestId = requestId) } } - state.errorRequestId?.let { RequestIdRow(requestId = it) } } } } @@ -175,6 +231,43 @@ fun ImportDataScreen( } } +@Composable +private fun ImportSourceCard( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Surface( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Column( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + ) + } + } +} + +private const val TYPE_TYPE_SECTION_INDEX = 3 +private const val YOUTUBE_SECTION_INDEX = 4 +private const val PIPEPIPE_SECTION_INDEX = 5 + private const val YOUTUBE_TAKEOUT_URL = "https://takeout.google.com/settings/takeout/custom/youtube,my_activity?dest=mail&frequency=once" diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityAssets.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityAssets.kt new file mode 100644 index 00000000..7f409658 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityAssets.kt @@ -0,0 +1,72 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +private val FORMAT_NAMES = mapOf( + "typetype" to "TypeType", + "pipepipe" to "PipePipe", + "newpipe" to "NewPipe", + "invidious" to "Invidious", + "piped" to "Piped", + "libretube" to "LibreTube", + "viewtube" to "ViewTube", + "materialious" to "Materialious", + "youtube-local" to "youtube-local", + "flow" to "Flow", + "skytube" to "SkyTube", + "grayjay" to "Grayjay", + "youtube-takeout" to "YouTube Takeout", + "opml" to "OPML", +) + +internal fun portabilityFormatName(format: String): String = + FORMAT_NAMES[format] ?: format + +@Composable +internal fun PortabilityFormatIcon(format: String, modifier: Modifier = Modifier) { + val context = LocalContext.current + val model = remember(context, format) { assetUri(format) } + if (model.isBlank()) { + Icon( + imageVector = Icons.Filled.Archive, + contentDescription = null, + modifier = modifier.size(24.dp), + ) + } else { + AsyncImage( + model = model, + contentDescription = null, + modifier = modifier.size(24.dp), + ) + } +} + +private fun assetUri(format: String): String { + val fileName = when (format) { + "typetype" -> "typetype.svg" + "pipepipe" -> "pipepipe.png" + "youtube-takeout" -> "youtube-takeout.svg" + "opml" -> "rss.svg" + "invidious", "libretube", "newpipe", "piped" -> "$format.svg" + else -> OFFICIAL_ASSETS[format] + } ?: return "" + return "file:///android_asset/portability-formats/$fileName" +} + +private val OFFICIAL_ASSETS = mapOf( + "flow" to "flow.png", + "grayjay" to "grayjay.png", + "materialious" to "materialious.png", + "skytube" to "skytube.png", + "viewtube" to "viewtube.png", + "youtube-local" to "youtube-local.ico", +) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityCategoryGrid.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityCategoryGrid.kt new file mode 100644 index 00000000..d63158b8 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityCategoryGrid.kt @@ -0,0 +1,163 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityFidelity +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.TypeTypeBackupCategory + +@Composable +internal fun PortabilityCategoryGrid( + format: PortabilityFormat?, + direction: PortabilityDirection, + selected: Set, + counts: Map = emptyMap(), + showStatuses: Boolean = false, + onToggle: (TypeTypeBackupCategory) -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + TypeTypeBackupCategory.entries.forEachIndexed { index, category -> + val supported = format?.supports(direction, category) == true + val enabled = supported && (counts.isEmpty() || counts.containsKey(category)) + if (index != 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + CategoryRow( + category = category, + selected = selected, + counts = counts, + showStatuses = showStatuses, + format = format, + direction = direction, + supported = supported, + enabled = enabled, + onToggle = onToggle, + ) + } + } + } +} + +@Composable +private fun CategoryRow( + category: TypeTypeBackupCategory, + selected: Set, + counts: Map, + showStatuses: Boolean, + format: PortabilityFormat?, + direction: PortabilityDirection, + supported: Boolean, + enabled: Boolean, + onToggle: (TypeTypeBackupCategory) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 64.dp) + .clickable(enabled = enabled, role = Role.Checkbox) { onToggle(category) } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Checkbox(checked = category in selected, onCheckedChange = { onToggle(category) }, enabled = enabled) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = categoryLabel(category), + style = MaterialTheme.typography.bodyMedium, + color = if (supported) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + counts[category]?.let { count -> + Text(count.toString(), style = MaterialTheme.typography.labelLarge) + } + if (showStatuses) CategoryStatusIcon(format, direction, category) + } + Text( + text = categoryDetail(category), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CategoryStatusIcon( + format: PortabilityFormat?, + direction: PortabilityDirection, + category: TypeTypeBackupCategory, +) { + val capability = format?.capabilities?.firstOrNull { + it.category == category && direction in it.directions + } ?: return + if (capability.fidelity == PortabilityFidelity.Partial) { + Icon(Icons.Outlined.Warning, contentDescription = null) + } else { + Icon(Icons.Outlined.CheckCircle, contentDescription = null) + } +} + +@Composable +internal fun categoryLabel(category: TypeTypeBackupCategory): String = when (category) { + TypeTypeBackupCategory.Subscriptions -> stringResource(R.string.settings_import_subscriptions) + TypeTypeBackupCategory.SubscriptionGroups -> stringResource(R.string.settings_import_subscription_groups) + TypeTypeBackupCategory.History -> stringResource(R.string.settings_import_history) + TypeTypeBackupCategory.Playlists -> stringResource(R.string.settings_import_playlists) + TypeTypeBackupCategory.WatchLater -> stringResource(R.string.settings_backup_watch_later) + TypeTypeBackupCategory.Favorites -> stringResource(R.string.settings_backup_favorites) + TypeTypeBackupCategory.Progress -> stringResource(R.string.settings_import_progress) + TypeTypeBackupCategory.SearchHistory -> stringResource(R.string.settings_import_search_history) + TypeTypeBackupCategory.SavedPlaylists -> stringResource(R.string.settings_backup_saved_playlists) + TypeTypeBackupCategory.Settings -> stringResource(R.string.settings_backup_settings) + TypeTypeBackupCategory.ContentFilters -> stringResource(R.string.settings_backup_content_filters) +} + +@Composable +private fun categoryDetail(category: TypeTypeBackupCategory): String = when (category) { + TypeTypeBackupCategory.Subscriptions -> + stringResource(R.string.portability_category_subscriptions_detail) + TypeTypeBackupCategory.SubscriptionGroups -> + stringResource(R.string.portability_category_subscription_groups_detail) + TypeTypeBackupCategory.History -> stringResource(R.string.portability_category_history_detail) + TypeTypeBackupCategory.Playlists -> stringResource(R.string.portability_category_playlists_detail) + TypeTypeBackupCategory.WatchLater -> stringResource(R.string.portability_category_watch_later_detail) + TypeTypeBackupCategory.Favorites -> stringResource(R.string.portability_category_favorites_detail) + TypeTypeBackupCategory.Progress -> stringResource(R.string.portability_category_progress_detail) + TypeTypeBackupCategory.SearchHistory -> + stringResource(R.string.portability_category_search_history_detail) + TypeTypeBackupCategory.SavedPlaylists -> + stringResource(R.string.portability_category_saved_playlists_detail) + TypeTypeBackupCategory.Settings -> stringResource(R.string.portability_category_settings_detail) + TypeTypeBackupCategory.ContentFilters -> + stringResource(R.string.portability_category_content_filters_detail) +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormControls.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormControls.kt new file mode 100644 index 00000000..a011c6fa --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormControls.kt @@ -0,0 +1,265 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy + +@Composable +internal fun EmptyImportForm( + state: PortabilityUiState, + onFormatSelected: (dev.typetype.android.domain.imports.PortabilityFormat) -> Unit, + onChooseFile: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + PortabilityFormatPicker( + labelRes = R.string.portability_import_from, + formats = state.formats, + selected = state.selectedFormat, + onSelect = onFormatSelected, + ) + state.selectedFormat?.let { format -> + ImportGuide(format.format) + DropZone( + extension = format.defaultExtension, + onChooseFile = onChooseFile, + ) + } + } +} + +@Composable +private fun DropZone( + extension: String, + onChooseFile: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().height(176.dp) + .clickable(role = Role.Button, onClick = onChooseFile), + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(Icons.Filled.UploadFile, contentDescription = null) + Text( + text = stringResource(R.string.portability_choose_or_drop), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Text( + text = ".$extension", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ImportGuide(format: String) { + val uriHandler = LocalUriHandler.current + val name = portabilityFormatName(format) + val descriptionRes = when (format) { + "typetype" -> R.string.portability_guide_typetype_description + "youtube-takeout" -> R.string.portability_guide_takeout_description + "opml" -> R.string.portability_guide_opml_description + else -> null + } + val description = descriptionRes?.let { resource -> stringResource(resource) } + ?: stringResource(R.string.portability_guide_generic_description, name) + val steps = when (format) { + "typetype" -> listOf( + R.string.portability_guide_typetype_step_one, + R.string.portability_guide_typetype_step_two, + R.string.portability_guide_typetype_step_three, + ) + "youtube-takeout" -> listOf( + R.string.portability_guide_takeout_step_one, + R.string.portability_guide_takeout_step_two, + R.string.portability_guide_takeout_step_three, + ) + "opml" -> listOf( + R.string.portability_guide_opml_step_one, + R.string.portability_guide_opml_step_two, + R.string.portability_guide_opml_step_three, + ) + else -> listOf( + R.string.portability_guide_generic_open, + R.string.portability_guide_generic_download, + R.string.portability_guide_generic_drop, + ) + }.map { resource -> + if (resource == R.string.portability_guide_generic_open) { + stringResource(resource, name) + } else { + stringResource(resource) + } + } + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.portability_get_backup, name), style = MaterialTheme.typography.bodyMedium) + Text( + description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + if (format == "youtube-takeout") { + PanelButton( + text = stringResource(R.string.portability_guide_takeout_action), + enabled = true, + onClick = { uriHandler.openUri("https://takeout.google.com/settings/takeout/custom/youtube,my_activity?dest=mail&frequency=once") }, + ) + } + } + HorizontalDivider() + steps.forEachIndexed { index, step -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Text("$index", style = MaterialTheme.typography.labelLarge) + Text(step, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + +@Composable +internal fun ReadyImportPreview( + state: PortabilityUiState, + onCategoryToggled: (dev.typetype.android.domain.imports.TypeTypeBackupCategory) -> Unit, + onPolicySelected: (PortabilityDuplicatePolicy) -> Unit, + onApplyImport: () -> Unit, + onResetJob: () -> Unit, +) { + val preview = requireNotNull(state.job?.preview) + val detectionFormat = preview.detection?.format + ?.let { wireName -> state.formats.firstOrNull { it.format == wireName } } + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + PortabilityFormatIcon(preview.detection?.format.orEmpty()) + Column(modifier = Modifier.weight(1f)) { + Text(portabilityFormatName(preview.detection?.format.orEmpty())) + Text( + "${preview.duplicates} ${stringResource(R.string.portability_duplicate_records)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface(onClick = onResetJob, shape = MaterialTheme.shapes.extraSmall) { + Row(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.height(14.dp)) + Text(stringResource(R.string.portability_different_file), style = MaterialTheme.typography.labelSmall) + } + } + } + PortabilityCategoryGrid( + format = detectionFormat, + direction = PortabilityDirection.Import, + selected = state.selectedCategories, + counts = preview.counts, + onToggle = onCategoryToggled, + ) + if (preview.issues.isNotEmpty()) { + Surface(shape = MaterialTheme.shapes.extraSmall, color = MaterialTheme.colorScheme.surfaceVariant) { + Column(modifier = Modifier.padding(12.dp)) { + Text(stringResource(R.string.portability_compatibility_notes), style = MaterialTheme.typography.labelMedium) + preview.issues.forEach { issue -> + Text("${issue.message} (${issue.count})", style = MaterialTheme.typography.bodySmall) + } + } + } + } + Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) { + listOf(PortabilityDuplicatePolicy.Skip, PortabilityDuplicatePolicy.Replace).forEach { policy -> + val active = policy == state.duplicatePolicy + Surface( + modifier = Modifier.clickable(role = Role.Button) { onPolicySelected(policy) }, + color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surface, + contentColor = if (active) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.onSurfaceVariant, + shape = MaterialTheme.shapes.extraSmall, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + stringResource( + if (policy == PortabilityDuplicatePolicy.Skip) { + R.string.portability_skip + } else { + R.string.portability_replace + }, + ), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + PanelButton( + stringResource(R.string.portability_import_selected), + enabled = state.selectedCategories.isNotEmpty() && !state.isApplying, + emphasized = true, + onClick = onApplyImport, + ) + } + } +} + +@Composable +internal fun PanelButton( + text: String, + enabled: Boolean = true, + emphasized: Boolean = false, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + Surface( + modifier = modifier.height(36.dp) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick), + shape = MaterialTheme.shapes.extraSmall, + color = if (emphasized) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surface, + contentColor = if (emphasized) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.onSurfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + ) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormatPicker.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormatPicker.kt new file mode 100644 index 00000000..dd1bcc56 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityFormatPicker.kt @@ -0,0 +1,104 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.imports.PortabilityFormat + +@Composable +internal fun PortabilityFormatPicker( + labelRes: Int, + formats: List, + selected: PortabilityFormat?, + onSelect: (PortabilityFormat) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Column { + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clickable(enabled = selected != null, role = Role.Button) { expanded = true }, + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PortabilityFormatIcon(format = selected?.format.orEmpty()) + Column(modifier = Modifier.weight(1f)) { + Text( + text = selected?.let { portabilityFormatName(it.format) }.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + Text( + text = selected?.let { ".${it.defaultExtension}" }.orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.Filled.ExpandMore, contentDescription = null) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + formats.forEach { format -> + DropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PortabilityFormatIcon(format.format) + Column(modifier = Modifier.weight(1f)) { + Text(portabilityFormatName(format.format)) + Text( + text = ".${format.defaultExtension}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onSelect(format) + expanded = false + }, + ) + } + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityJobCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityJobCard.kt new file mode 100644 index 00000000..345bda36 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityJobCard.kt @@ -0,0 +1,183 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.imports.PortabilityJob +import dev.typetype.android.domain.imports.PortabilityJobState + +@Composable +internal fun PortabilityJobCard( + job: PortabilityJob, + isCancelling: Boolean, + onCancel: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + val active = job.state in setOf( + PortabilityJobState.Queued, + PortabilityJobState.Analyzing, + PortabilityJobState.Applying, + PortabilityJobState.Encoding, + ) + val percent = job.progress?.total?.takeIf { it > 0 }?.let { total -> + (job.progress.processed * 100f / total).toInt().coerceIn(0, 100) + } + + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + when { + active -> CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + job.state == PortabilityJobState.Failed -> Icon( + Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + else -> Icon(Icons.Outlined.CheckCircle, contentDescription = null) + } + Column(modifier = Modifier.weight(1f)) { + Text(job.stateLabel(), style = MaterialTheme.typography.bodyMedium) + job.progress?.let { progress -> + Text( + text = buildString { + append(progress.processed) + progress.total?.let { append(" / $it") } + append(" ${progress.unit.orEmpty()}") + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (job.state == PortabilityJobState.Failed) { + Text( + text = job.errorMessage + ?: stringResource(R.string.portability_operation_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + if (active) { + IconButton(onClick = onCancel, enabled = !isCancelling) { + Icon(Icons.Outlined.Close, stringResource(R.string.portability_cancel)) + } + } + } + + if (active) { + Box( + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + .height(6.dp) + .background(progressColor()), + ) { + Box( + modifier = Modifier + .fillMaxWidth(if (percent == null) 0.33f else percent / 100f) + .height(6.dp) + .background(MaterialTheme.colorScheme.primary), + ) + } + } + + job.result.forEach { (category, count) -> + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(categoryLabel(category), style = MaterialTheme.typography.labelMedium) + Text(count.toString(), style = MaterialTheme.typography.bodySmall) + } + } + + HorizontalDivider(modifier = Modifier.padding(top = 12.dp)) + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("${stringResource(R.string.portability_job_label)}: ${job.id}", style = MaterialTheme.typography.labelSmall) + job.requestId?.let { + Text("${stringResource(R.string.portability_request_label)}: $it", style = MaterialTheme.typography.labelSmall) + } + job.errorCode?.let { + Text("${stringResource(R.string.portability_code_label)}: $it", style = MaterialTheme.typography.labelSmall) + } + } + IconButton(onClick = { + clipboard.setText( + AnnotatedString(listOfNotNull(job.id, job.requestId, job.errorCode).joinToString("\n")), + ) + copied = true + }) { + Icon(if (copied) Icons.Filled.Check else Icons.Outlined.ContentCopy, null) + } + } + } + } +} + +@Composable +private fun progressColor(): Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + +@Composable +private fun PortabilityJob.stateLabel(): String = when (state) { + PortabilityJobState.Queued -> stringResource(R.string.portability_job_waiting) + PortabilityJobState.Analyzing -> stringResource(R.string.portability_job_analyzing) + PortabilityJobState.Ready -> stringResource(R.string.portability_job_ready) + PortabilityJobState.Applying -> stringResource(R.string.portability_job_importing) + PortabilityJobState.Encoding -> stringResource(R.string.portability_job_generating) + PortabilityJobState.Completed -> stringResource( + if (kind == dev.typetype.android.domain.imports.PortabilityDirection.Import) { + R.string.portability_job_import_completed + } else { + R.string.portability_job_export_ready + }, + ) + PortabilityJobState.Cancelled -> stringResource(R.string.portability_job_cancelled) + PortabilityJobState.Failed -> stringResource(R.string.portability_job_failed) +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityModePanels.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityModePanels.kt new file mode 100644 index 00000000..2be31b87 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityModePanels.kt @@ -0,0 +1,224 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityFidelity +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.PortabilityJobState +import dev.typetype.android.domain.imports.TypeTypeBackupCategory + +@Composable +internal fun ExportPanel( + state: PortabilityUiState, + onFormatSelected: (PortabilityFormat) -> Unit, + onSelectAllCategories: (Set) -> Unit, + onCategoryToggled: (TypeTypeBackupCategory) -> Unit, + onStartExport: () -> Unit, + onCancelJob: () -> Unit, + onResetJob: () -> Unit, + onDownloadArtifact: () -> Unit, + onDownloadReport: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + SectionHeader(icon = { + Icon(Icons.Filled.Download, contentDescription = null) + }, title = R.string.portability_export_title, subtitle = R.string.portability_export_description) + + val job = state.job + if (job == null) { + if (state.formats.isEmpty()) { + Text( + text = stringResource(R.string.portability_no_export_format), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + PortabilityFormatPicker( + labelRes = R.string.portability_destination_format, + formats = state.formats, + selected = state.selectedFormat, + onSelect = onFormatSelected, + ) + if (state.selectedFormat != null) { + val available = TypeTypeBackupCategory.entries.filter { category -> + state.selectedFormat.supports(state.mode.direction, category) + } + Row( + modifier = Modifier.fillMaxWidth().toggleable( + value = available.isNotEmpty() && + available.all(state.selectedCategories::contains), + role = Role.Checkbox, + ) { checked -> + onSelectAllCategories( + if (checked) available.toSet() else emptySet(), + ) + }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = available.isNotEmpty() && + available.all(state.selectedCategories::contains), + onCheckedChange = { checked -> + onSelectAllCategories( + if (checked) available.toSet() else emptySet(), + ) + }, + ) + Text(stringResource(R.string.portability_select_all)) + } + PortabilityCategoryGrid( + format = state.selectedFormat, + direction = state.mode.direction, + selected = state.selectedCategories, + showStatuses = true, + onToggle = onCategoryToggled, + ) + if (state.selectedFormat.capabilities.any { capability -> + PortabilityDirection.Export in capability.directions && + capability.fidelity == PortabilityFidelity.Partial + }) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) { + Text( + stringResource(R.string.portability_export_partial_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + } + } + if (state.selectedFormat != null) { + HorizontalDivider() + PanelButton( + text = stringResource(R.string.portability_generate_export), + enabled = state.selectedCategories.isNotEmpty() && !state.isStartingJob, + emphasized = true, + onClick = onStartExport, + modifier = Modifier.align(Alignment.End), + ) + } + } else { + PortabilityJobCard(job, state.isCancelling, onCancelJob) + if (job.state == PortabilityJobState.Completed) { + job.preview?.issues?.takeIf { issues -> issues.isNotEmpty() }?.let { issues -> + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(stringResource(R.string.portability_export_notes), style = MaterialTheme.typography.labelMedium) + issues.forEach { issue -> + Text("${issue.message} (${issue.count})", style = MaterialTheme.typography.bodySmall) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PanelButton( + text = stringResource(R.string.portability_download_export), + enabled = !state.isSavingArtifact, + emphasized = true, + onClick = onDownloadArtifact, + ) + PanelButton( + stringResource(R.string.portability_download_report), + enabled = true, + onClick = onDownloadReport, + ) + } + } + if (job.state.isTerminalUiState()) { + PanelButton(stringResource(R.string.portability_start_another_export), onClick = onResetJob) + } + } + } +} + +@Composable +internal fun ImportPanel( + state: PortabilityUiState, + onFormatSelected: (PortabilityFormat) -> Unit, + onCategoryToggled: (TypeTypeBackupCategory) -> Unit, + onPolicySelected: (PortabilityDuplicatePolicy) -> Unit, + onChooseFile: () -> Unit, + onApplyImport: () -> Unit, + onCancelJob: () -> Unit, + onResetJob: () -> Unit, + onDownloadReport: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + SectionHeader(icon = { + Icon(Icons.Filled.UploadFile, contentDescription = null) + }, title = R.string.portability_import_title, subtitle = R.string.portability_import_description) + val job = state.job + when { + job == null -> { + if (state.formats.isEmpty()) { + Text( + text = stringResource(R.string.portability_no_import_format), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + EmptyImportForm(state, onFormatSelected, onChooseFile) + } + job.state == PortabilityJobState.Ready -> ReadyImportPreview( + state = state, + onCategoryToggled = onCategoryToggled, + onPolicySelected = onPolicySelected, + onApplyImport = onApplyImport, + onResetJob = onResetJob, + ) + else -> { + PortabilityJobCard(job, state.isCancelling, onCancelJob) + if (job.state.isTerminalUiState()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PanelButton( + stringResource(R.string.portability_start_another_import), + onClick = onResetJob, + ) + PanelButton( + stringResource(R.string.portability_download_report), + enabled = true, + onClick = onDownloadReport, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityPanel.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityPanel.kt new file mode 100644 index 00000000..23126763 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityPanel.kt @@ -0,0 +1,154 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.RequestIdRow +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy + +@Composable +internal fun PortabilityPanel( + state: PortabilityUiState, + onModeSelected: (PortabilityScreenMode) -> Unit, + onFormatSelected: (dev.typetype.android.domain.imports.PortabilityFormat) -> Unit, + onSelectAllCategories: (Set) -> Unit, + onCategoryToggled: (dev.typetype.android.domain.imports.TypeTypeBackupCategory) -> Unit, + onPolicySelected: (PortabilityDuplicatePolicy) -> Unit, + onStartExport: () -> Unit, + onChooseFile: () -> Unit, + onApplyImport: () -> Unit, + onCancelJob: () -> Unit, + onResetJob: () -> Unit, + onDownloadArtifact: () -> Unit, + onDownloadReport: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(20.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) { + PortabilityScreenMode.entries.forEach { mode -> + val active = state.mode == mode + Surface( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clickable(role = Role.Tab) { onModeSelected(mode) }, + color = if (active) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.surface, + contentColor = if (active) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.onSurfaceVariant, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + shape = MaterialTheme.shapes.extraSmall, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (mode == PortabilityScreenMode.Import) { + Icons.Filled.UploadFile + } else { + Icons.Filled.Download + }, + contentDescription = null, + modifier = Modifier.height(15.dp), + ) + Text( + text = stringResource( + if (mode == PortabilityScreenMode.Import) { + R.string.settings_portability_import + } else { + R.string.settings_portability_export + }, + ), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + + if (state.isLoadingFormats) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator(modifier = Modifier.height(18.dp)) + Text(stringResource(R.string.portability_loading_formats), style = MaterialTheme.typography.bodyMedium) + } + } else { + when (state.mode) { + PortabilityScreenMode.Export -> ExportPanel( + state = state, + onFormatSelected = onFormatSelected, + onSelectAllCategories = onSelectAllCategories, + onCategoryToggled = onCategoryToggled, + onStartExport = onStartExport, + onCancelJob = onCancelJob, + onResetJob = onResetJob, + onDownloadArtifact = onDownloadArtifact, + onDownloadReport = onDownloadReport, + ) + PortabilityScreenMode.Import -> ImportPanel( + state = state, + onFormatSelected = onFormatSelected, + onCategoryToggled = onCategoryToggled, + onPolicySelected = onPolicySelected, + onChooseFile = onChooseFile, + onApplyImport = onApplyImport, + onCancelJob = onCancelJob, + onResetJob = onResetJob, + onDownloadReport = onDownloadReport, + ) + } + } + state.failureMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + } + state.failureCode?.let { code -> + Text( + text = stringResource(R.string.error_code, code), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + ) + } + state.failureRequestId?.let { requestId -> + RequestIdRow(requestId = requestId) + } + } +} + +@Composable +internal fun SectionHeader(icon: @Composable () -> Unit, title: Int, subtitle: Int) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + icon() + Column { + Text(stringResource(title), style = MaterialTheme.typography.titleMedium) + Text( + stringResource(subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityRoute.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityRoute.kt new file mode 100644 index 00000000..04982448 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityRoute.kt @@ -0,0 +1,104 @@ +package dev.typetype.android.feature.settings.imports + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.activity.compose.BackHandler +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.typetype.android.R +import dev.typetype.android.feature.settings.SettingsDetailTopBar + +@Composable +fun PortabilityRoute( + onNavigateBack: () -> Unit, + viewModel: PortabilityViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val activeJob = state.job + BackHandler(enabled = activeJob != null && !activeJob.isTerminal) { } + val artifactPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream"), + onResult = { uri -> uri?.let(viewModel::downloadArtifact) }, + ) + val reportPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + onResult = { uri -> uri?.let(viewModel::downloadReport) }, + ) + val importPicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> uri?.let(viewModel::chooseImportFile) }, + ) + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Column(modifier = Modifier.fillMaxSize()) { + SettingsDetailTopBar( + title = stringResource(R.string.settings_import_title), + onNavigateBack = onNavigateBack, + ) + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + Text( + text = stringResource(R.string.settings_import_portability_title), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.settings_import_portability_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + PortabilityPanel( + state = state, + onModeSelected = viewModel::selectMode, + onFormatSelected = viewModel::selectFormat, + onSelectAllCategories = viewModel::setExportSelection, + onCategoryToggled = viewModel::toggleCategory, + onPolicySelected = viewModel::setDuplicatePolicy, + onStartExport = viewModel::startExport, + onChooseFile = { + importPicker.launch(arrayOf("*/*")) + }, + onApplyImport = viewModel::applyImport, + onCancelJob = viewModel::cancel, + onResetJob = viewModel::resetJob, + onDownloadArtifact = { + val format = state.selectedFormat + val id = state.job?.id + if (format != null && id != null) { + artifactPicker.launch("typetype-$id.${format.defaultExtension}") + } else { + artifactPicker.launch("typetype-export") + } + }, + onDownloadReport = { + val id = state.job?.id + reportPicker.launch("typetype-report-$id.json") + }, + ) + } + if (state.isLoadingFormats) { + item { CircularProgressIndicator() } + } + } + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiExtensions.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiExtensions.kt new file mode 100644 index 00000000..168e79b0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiExtensions.kt @@ -0,0 +1,8 @@ +package dev.typetype.android.feature.settings.imports + +import dev.typetype.android.domain.imports.PortabilityJobState + +internal fun PortabilityJobState.isTerminalUiState(): Boolean = + this == PortabilityJobState.Completed || + this == PortabilityJobState.Failed || + this == PortabilityJobState.Cancelled diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiState.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiState.kt new file mode 100644 index 00000000..a564451d --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityUiState.kt @@ -0,0 +1,47 @@ +package dev.typetype.android.feature.settings.imports + +import dev.typetype.android.domain.imports.ImportDocument +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy +import dev.typetype.android.domain.imports.PortabilityDirection +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.PortabilityJob +import dev.typetype.android.domain.imports.PortabilityJobState +import dev.typetype.android.domain.imports.TypeTypeBackupCategory + +data class PortabilityUiState( + val mode: PortabilityScreenMode = PortabilityScreenMode.Import, + val formats: List = emptyList(), + val isLoadingFormats: Boolean = false, + val selectedFormat: PortabilityFormat? = null, + val selectedCategories: Set = emptySet(), + val duplicatePolicy: PortabilityDuplicatePolicy = + PortabilityDuplicatePolicy.Skip, + val job: PortabilityJob? = null, + val isStartingJob: Boolean = false, + val isApplying: Boolean = false, + val isCancelling: Boolean = false, + val isSavingArtifact: Boolean = false, + val selectedDocumentName: String? = null, + val failureMessage: String? = null, + val failureCode: String? = null, + val failureRequestId: String? = null, +) { + val availableCategories: Set + get() { + val format = selectedFormat ?: return emptySet() + return TypeTypeBackupCategory.entries.filterTo(sortedSetOf()) { category -> + format.supports(mode.direction, category) + } + } + + val previewCategories: Set + get() = job?.preview?.counts?.keys.orEmpty() + + val activeCategories: Set + get() = if (job?.state == PortabilityJobState.Ready) previewCategories else availableCategories +} + +enum class PortabilityScreenMode(val direction: PortabilityDirection) { + Export(PortabilityDirection.Export), + Import(PortabilityDirection.Import), +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityViewModel.kt new file mode 100644 index 00000000..436d64f9 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/PortabilityViewModel.kt @@ -0,0 +1,253 @@ +package dev.typetype.android.feature.settings.imports + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.domain.imports.ImportDocument +import dev.typetype.android.domain.imports.PortabilityDuplicatePolicy +import dev.typetype.android.domain.imports.PortabilityJob +import dev.typetype.android.domain.imports.PortabilityFormat +import dev.typetype.android.domain.imports.PortabilityJobState +import dev.typetype.android.domain.imports.PortabilityRepository +import dev.typetype.android.domain.imports.TypeTypeBackupCategory +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@HiltViewModel +class PortabilityViewModel @Inject constructor( + private val portabilityRepository: PortabilityRepository, + private val documentReader: ImportDocumentReader, +) : ViewModel() { + private val _state = MutableStateFlow(PortabilityUiState()) + val state = _state.asStateFlow() + + init { + loadFormats() + } + + fun loadFormats() { + viewModelScope.launch { + _state.update { it.clearFailure().copy(isLoadingFormats = true) } + portabilityRepository.formats() + .onSuccess { formats -> + val mode = _state.value.mode + val supported = formats.filter { format -> + format.capabilities.any { capability -> mode.direction in capability.directions } + } + val selected = supported.firstOrNull() + _state.update { + it.copy( + formats = supported, + selectedFormat = selected, + selectedCategories = emptySet(), + isLoadingFormats = false, + ) + } + } + .onFailure(::showFailure) + } + } + + fun selectMode(mode: PortabilityScreenMode) { + val formats = _state.value.formats.filter { format -> + format.capabilities.any { capability -> mode.direction in capability.directions } + } + val selected = formats.firstOrNull() + _state.update { + it.copy( + mode = mode, + formats = formats, + selectedFormat = selected, + selectedCategories = emptySet(), + job = null, + failureCode = null, + failureRequestId = null, + ) + } + } + + fun selectFormat(format: PortabilityFormat) { + _state.update { + it.copy( + selectedFormat = format, + selectedCategories = emptySet(), + job = null, + ) + } + } + + fun toggleCategory(category: TypeTypeBackupCategory) { + _state.update { current -> + val values = current.selectedCategories.toMutableSet() + if (!values.add(category)) values.remove(category) + current.copy(selectedCategories = values) + } + } + + fun setExportSelection(categories: Set) { + if (_state.value.isStartingJob) return + _state.update { it.copy(selectedCategories = categories) } + } + + fun setDuplicatePolicy(policy: PortabilityDuplicatePolicy) = + _state.update { it.copy(duplicatePolicy = policy) } + + fun startExport() { + val format = _state.value.selectedFormat ?: return + val categories = _state.value.selectedCategories + if (_state.value.isStartingJob || categories.isEmpty()) return + viewModelScope.launch { + busy(true) + portabilityRepository.startExport(format.format, categories) + .onSuccess { watch(it.id) } + .onFailure(::showFailure) + busy(false) + } + } + + fun chooseImportFile(uri: Uri) { + viewModelScope.launch { + runCatching { + documentReader.read(uri, "portability-import") + }.onSuccess(::uploadImport) + .onFailure(::showFailure) + } + } + + fun applyImport() { + val job = _state.value.job ?: return + if (_state.value.isApplying || job.state != PortabilityJobState.Ready) return + viewModelScope.launch { + _state.update { it.clearFailure() } + portabilityRepository.applyImport( + jobId = job.id, + categories = _state.value.selectedCategories, + duplicatePolicy = _state.value.duplicatePolicy, + ).onSuccess { updated -> + _state.update { it.copy(job = updated) } + watch(updated.id) + }.onFailure(::showFailure) + _state.update { it.copy(isApplying = false) } + } + } + + fun cancel() { + val jobId = _state.value.job?.id ?: return + viewModelScope.launch { + _state.update { it.copy(isCancelling = true) } + portabilityRepository.cancel(jobId) + .onSuccess { updated -> _state.update { it.copy(job = updated) } } + .onFailure(::showFailure) + _state.update { it.copy(isCancelling = false) } + } + } + + fun downloadArtifact(destination: Uri) { + val jobId = _state.value.job?.id ?: return + viewModelScope.launch { + _state.update { it.clearFailure() } + portabilityRepository.downloadArtifact(jobId, destination.toString()) + .onSuccess { _state.update { current -> current.clearFailure() } } + .onFailure(::showFailure) + _state.update { it.copy(isSavingArtifact = false) } + } + } + + fun downloadReport(destination: Uri) { + val jobId = _state.value.job?.id ?: return + viewModelScope.launch { + portabilityRepository.downloadReport(jobId, destination.toString()) + .onFailure(::showFailure) + } + } + + fun resetJob() { + val jobId = _state.value.job?.id + _state.update { + it.clearFailure().copy(job = null, selectedCategories = emptySet()) + } + jobId?.let { id -> + viewModelScope.launch { portabilityRepository.delete(id) } + } + } + + private fun uploadImport(document: ImportDocument) { + val format = _state.value.selectedFormat ?: return + viewModelScope.launch { + busy(true) + _state.update { it.copy(selectedDocumentName = document.displayName) } + portabilityRepository.startImport(document, format.format) + .onSuccess { watch(it.id) } + .onFailure(::showFailure) + busy(false) + } + } + + private fun watch(jobId: String) { + viewModelScope.launch { + while (true) { + portabilityRepository.job(jobId) + .onSuccess { updated -> + _state.update { it.copy(job = updated) } + if (updated.state == PortabilityJobState.Ready || + updated.state.isTerminalState + ) { + if (updated.state == PortabilityJobState.Ready) { + selectPreviewCategories(updated) + } + return@launch + } + } + .onFailure(::showFailure) + delay(POLL_INTERVAL_MILLIS) + } + } + } + + private fun selectPreviewCategories(updated: PortabilityJob) { + _state.update { it.copy(selectedCategories = updated.preview?.counts?.keys.orEmpty()) } + } + + private val PortabilityJobState.isTerminalState + get() = this == PortabilityJobState.Completed || + this == PortabilityJobState.Failed || + this == PortabilityJobState.Cancelled + + private fun busy(enabled: Boolean) = _state.update { + if (enabled) { + it.clearFailure().copy(isStartingJob = true) + } else { + it.copy(isStartingJob = false) + } + } + + private fun showFailure(failure: Throwable) = _state.update { + val codedFailure = failure as? CodedFailure + it.copy( + isLoadingFormats = false, + isStartingJob = false, + isApplying = false, + isSavingArtifact = false, + failureMessage = failure.message ?: DEFAULT_FAILURE_MESSAGE, + failureCode = codedFailure?.failureCode, + failureRequestId = codedFailure?.requestId, + ) + } + + private fun PortabilityUiState.clearFailure(): PortabilityUiState = copy( + failureMessage = null, + failureCode = null, + failureRequestId = null, + ) + + private companion object { + const val POLL_INTERVAL_MILLIS = 1_000L + const val DEFAULT_FAILURE_MESSAGE = "Portability request failed" + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/imports/TypeTypeBackupSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/imports/TypeTypeBackupSection.kt index d34a4c97..84cb3046 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/imports/TypeTypeBackupSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/imports/TypeTypeBackupSection.kt @@ -31,44 +31,52 @@ internal fun TypeTypeBackupSection( onExport: () -> Unit, onChooseBackup: () -> Unit, onResetResult: () -> Unit, + showExportControls: Boolean = false, + showRestoreControls: Boolean = false, ) { Text( text = stringResource(R.string.settings_backup_typetype_title), style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), ) - Text( - text = stringResource(R.string.settings_backup_typetype_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (showExportControls) { + Text( + text = stringResource(R.string.settings_backup_typetype_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } state.typeTypeSummary?.let { summary -> TypeTypeRestoreSummaryCard(summary, onResetResult) } ?: TypeTypeCard { - TypeTypeBackupCategory.entries.forEach { category -> - BackupCategoryRow( - category = category, - checked = category in state.selectedCategories, - enabled = !state.isExportingTypeType && !state.isRestoringTypeType, - onToggle = { onToggleCategory(category) }, + if (showExportControls) { + TypeTypeBackupCategory.entries.forEach { category -> + BackupCategoryRow( + category = category, + checked = category in state.selectedCategories, + enabled = !state.isExportingTypeType && !state.isRestoringTypeType, + onToggle = { onToggleCategory(category) }, + ) + } + TypeTypePrimaryButton( + text = stringResource(R.string.settings_backup_export_selected), + onClick = onExport, + enabled = state.canExportTypeType, + isLoading = state.isExportingTypeType, + modifier = Modifier.padding(top = 12.dp), ) + if (state.typeTypeExportComplete) { + Text( + text = stringResource(R.string.settings_backup_export_complete), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp), + ) + } } - TypeTypePrimaryButton( - text = stringResource(R.string.settings_backup_export_selected), - onClick = onExport, - enabled = state.canExportTypeType, - isLoading = state.isExportingTypeType, - modifier = Modifier.padding(top = 12.dp), - ) - TypeTypeSecondaryButton( - text = stringResource(R.string.settings_backup_restore_typetype), - onClick = onChooseBackup, - enabled = !state.isExportingTypeType && !state.isRestoringTypeType, - ) - if (state.typeTypeExportComplete) { - Text( - text = stringResource(R.string.settings_backup_export_complete), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp), + if (showRestoreControls) { + TypeTypeSecondaryButton( + text = stringResource(R.string.settings_backup_restore_typetype), + onClick = onChooseBackup, + enabled = !state.isExportingTypeType && !state.isRestoringTypeType, ) } Text( @@ -162,6 +170,7 @@ internal fun TypeTypeRestoreDialog( private fun TypeTypeBackupCategory.labelRes(): Int = when (this) { TypeTypeBackupCategory.Subscriptions -> R.string.settings_import_subscriptions + TypeTypeBackupCategory.SubscriptionGroups -> R.string.settings_import_subscription_groups TypeTypeBackupCategory.History -> R.string.settings_import_history TypeTypeBackupCategory.Playlists -> R.string.settings_import_playlists TypeTypeBackupCategory.WatchLater -> R.string.settings_backup_watch_later diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerGestureSettingsItems.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerGestureSettingsItems.kt new file mode 100644 index 00000000..31fe27ad --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerGestureSettingsItems.kt @@ -0,0 +1,79 @@ +package dev.typetype.android.feature.settings.player + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.typetype.android.R +import dev.typetype.android.core.ui.components.DropdownRow +import dev.typetype.android.core.ui.components.SettingsSectionHeader +import dev.typetype.android.core.ui.components.SwitchRow + +private val SEEK_SECONDS = listOf(5, 10, 15, 20, 30) + +internal fun LazyListScope.playerGestureSettingsItems( + state: PlayerSettingsState, + onAction: (PlayerSettingsAction) -> Unit, +) { + item { Spacer(Modifier.size(4.dp)) } + item { SettingsSectionHeader(stringResource(R.string.settings_player_section_gestures)) } + item { + SwitchRow( + title = stringResource(R.string.settings_player_accessible_controls), + subtitle = stringResource(R.string.settings_player_accessible_controls_subtitle), + checked = state.accessibleControlsEnabled, + onCheckedChange = { onAction(PlayerSettingsAction.SetAccessibleControls(it)) }, + ) + } + item { + SwitchRow( + title = stringResource(R.string.settings_player_double_tap_seek), + subtitle = stringResource(R.string.settings_player_double_tap_seek_subtitle), + checked = state.doubleTapSeekEnabled, + onCheckedChange = { onAction(PlayerSettingsAction.SetDoubleTapSeek(it)) }, + ) + } + item { + DropdownRow( + title = stringResource(R.string.settings_player_double_tap_seek_time), + subtitle = null, + options = SEEK_SECONDS.map { seconds -> + seconds.toString() to pluralStringResource( + R.plurals.settings_player_autoplay_seconds, + seconds, + seconds, + ) + }, + selectedKey = state.doubleTapSeekSeconds.toString(), + onSelect = { onAction(PlayerSettingsAction.SetDoubleTapSeekSeconds(it.toInt())) }, + enabled = state.doubleTapSeekEnabled, + ) + } + item { + SwitchRow( + title = stringResource(R.string.settings_player_swipe_seek), + subtitle = stringResource(R.string.settings_player_swipe_seek_subtitle), + checked = state.swipeSeekEnabled, + onCheckedChange = { onAction(PlayerSettingsAction.SetSwipeSeek(it)) }, + ) + } + item { + SwitchRow( + title = stringResource(R.string.settings_player_swipe_brightness_volume), + subtitle = stringResource(R.string.settings_player_swipe_brightness_volume_subtitle), + checked = state.swipeBrightnessVolumeEnabled, + onCheckedChange = { onAction(PlayerSettingsAction.SetSwipeBrightnessVolume(it)) }, + ) + } + item { + SwitchRow( + title = stringResource(R.string.settings_player_long_press_speed), + subtitle = stringResource(R.string.settings_player_long_press_speed_subtitle), + checked = state.longPressSpeedEnabled, + onCheckedChange = { onAction(PlayerSettingsAction.SetLongPressSpeed(it)) }, + ) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt index 703a8c0b..5afda711 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsAction.kt @@ -5,9 +5,12 @@ import dev.typetype.android.domain.usersettings.SponsorBlockMode sealed interface PlayerSettingsAction { data class SetDoubleTapSeek(val enabled: Boolean) : PlayerSettingsAction + data class SetDoubleTapSeekSeconds(val seconds: Int) : PlayerSettingsAction + data class SetPreferredCodec(val codec: String) : PlayerSettingsAction data class SetSwipeSeek(val enabled: Boolean) : PlayerSettingsAction data class SetSwipeBrightnessVolume(val enabled: Boolean) : PlayerSettingsAction data class SetLongPressSpeed(val enabled: Boolean) : PlayerSettingsAction + data class SetAccessibleControls(val enabled: Boolean) : PlayerSettingsAction data class SetAutoplay(val enabled: Boolean) : PlayerSettingsAction data class SetAutoplayCountdown(val seconds: Int) : PlayerSettingsAction data class SetSkipPlaylistAutoplayScreen(val enabled: Boolean) : PlayerSettingsAction diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt index ff1e4f65..11927495 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsScreen.kt @@ -20,8 +20,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.pluralStringResource @@ -36,6 +40,10 @@ import dev.typetype.android.core.ui.components.DropdownRow import dev.typetype.android.core.ui.components.ServiceRow import dev.typetype.android.core.ui.components.SettingsSectionHeader import dev.typetype.android.core.ui.components.SwitchRow +import dev.typetype.android.feature.player.AV1_CODEC_KEY +import dev.typetype.android.feature.player.H264_CODEC_KEY +import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY +import dev.typetype.android.feature.player.VP9_CODEC_KEY import dev.typetype.android.feature.settings.SettingsDetailTopBar private val QUALITY_OPTIONS = listOf("144p", "240p", "360p", "480p", "720p", "1080p", "1440p", "2160p") @@ -96,6 +104,13 @@ fun PlayerSettingsScreen( onNavigateBack: () -> Unit, onAction: (PlayerSettingsAction) -> Unit, ) { + var advancedExpanded by rememberSaveable { mutableStateOf(false) } + val codecOptions = listOf( + RECOMMENDED_CODEC_KEY to stringResource(R.string.playback_options_smart), + AV1_CODEC_KEY to stringResource(R.string.playback_options_codec_av1), + VP9_CODEC_KEY to stringResource(R.string.playback_options_codec_vp9), + H264_CODEC_KEY to stringResource(R.string.playback_options_codec_h264), + ) Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, @@ -166,6 +181,15 @@ fun PlayerSettingsScreen( }, ) } + item { + DropdownRow( + title = stringResource(R.string.settings_player_default_codec), + subtitle = stringResource(R.string.settings_player_default_codec_subtitle), + options = codecOptions, + selectedKey = state.preferredCodec, + onSelect = { onAction(PlayerSettingsAction.SetPreferredCodec(it)) }, + ) + } item { DropdownRow( title = stringResource(R.string.settings_player_default_quality), @@ -196,7 +220,6 @@ fun PlayerSettingsScreen( onCheckedChange = { onAction(PlayerSettingsAction.SetPauseInBackground(it)) }, ) } - if (state.defaultService == 0) { item { Spacer(Modifier.size(4.dp)) } item { SettingsSectionHeader(stringResource(R.string.settings_player_section_subtitles)) } @@ -240,12 +263,18 @@ fun PlayerSettingsScreen( onCheckedChange = { onAction(PlayerSettingsAction.SetPreferOriginalLanguage(it)) }, ) } - captionStyleSettingsItems(state = state, onAction = onAction) + if (advancedExpanded) { + captionStyleSettingsItems(state = state, onAction = onAction) + } } item { Spacer(Modifier.size(4.dp)) } item { SettingsSectionHeader(stringResource(R.string.settings_section_default_service)) } - items(SERVICES.size, key = { i -> "svc-${SERVICES[i].id}" }) { i -> + items( + SERVICES.size, + key = { i -> "svc-${SERVICES[i].id}" }, + contentType = { "player-service" }, + ) { i -> val svc = SERVICES[i] ServiceRow( title = stringResource(svc.labelRes), @@ -256,44 +285,27 @@ fun PlayerSettingsScreen( ) } - sponsorBlockSettingsItems(state = state, onAction = onAction) - - item { Spacer(Modifier.size(4.dp)) } - danmakuSettingsItems(state = state, onAction = onAction) - - item { Spacer(Modifier.size(4.dp)) } - item { SettingsSectionHeader(stringResource(R.string.settings_player_section_gestures)) } item { - SwitchRow( - title = stringResource(R.string.settings_player_double_tap_seek), - subtitle = stringResource(R.string.settings_player_double_tap_seek_subtitle), - checked = state.doubleTapSeekEnabled, - onCheckedChange = { onAction(PlayerSettingsAction.SetDoubleTapSeek(it)) }, - ) - } - item { - SwitchRow( - title = stringResource(R.string.settings_player_swipe_seek), - subtitle = stringResource(R.string.settings_player_swipe_seek_subtitle), - checked = state.swipeSeekEnabled, - onCheckedChange = { onAction(PlayerSettingsAction.SetSwipeSeek(it)) }, - ) - } - item { - SwitchRow( - title = stringResource(R.string.settings_player_swipe_brightness_volume), - subtitle = stringResource(R.string.settings_player_swipe_brightness_volume_subtitle), - checked = state.swipeBrightnessVolumeEnabled, - onCheckedChange = { onAction(PlayerSettingsAction.SetSwipeBrightnessVolume(it)) }, - ) + TextButton( + onClick = { advancedExpanded = !advancedExpanded }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource( + if (advancedExpanded) { + R.string.settings_player_advanced_hide + } else { + R.string.settings_player_advanced_show + }, + ), + ) + } } - item { - SwitchRow( - title = stringResource(R.string.settings_player_long_press_speed), - subtitle = stringResource(R.string.settings_player_long_press_speed_subtitle), - checked = state.longPressSpeedEnabled, - onCheckedChange = { onAction(PlayerSettingsAction.SetLongPressSpeed(it)) }, - ) + if (advancedExpanded) { + sponsorBlockSettingsItems(state = state, onAction = onAction) + item { Spacer(Modifier.size(4.dp)) } + danmakuSettingsItems(state = state, onAction = onAction) + playerGestureSettingsItems(state = state, onAction = onAction) } } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt index d2f8fddb..4f120a34 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsState.kt @@ -6,9 +6,12 @@ import dev.typetype.android.domain.usersettings.SponsorBlockMode data class PlayerSettingsState( val doubleTapSeekEnabled: Boolean = true, + val doubleTapSeekSeconds: Int = 10, + val preferredCodec: String = "recommended", val swipeSeekEnabled: Boolean = true, val swipeBrightnessVolumeEnabled: Boolean = true, val longPressSpeedEnabled: Boolean = true, + val accessibleControlsEnabled: Boolean = false, val autoplayEnabled: Boolean = true, val autoplayCountdownSeconds: Int = 10, val skipPlaylistAutoplayScreen: Boolean = false, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt index bd13bf47..3b64a1ca 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/PlayerSettingsViewModel.kt @@ -7,6 +7,7 @@ import dev.typetype.android.domain.preferences.PreferencesRepository import dev.typetype.android.domain.usersettings.DEFAULT_SPONSOR_BLOCK_CATEGORY_ACTIONS import dev.typetype.android.domain.usersettings.SponsorBlockMode import dev.typetype.android.domain.usersettings.UserSettings +import dev.typetype.android.domain.usersettings.normalizeQualityName import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -32,9 +33,12 @@ class PlayerSettingsViewModel @Inject constructor( ) { prefs, server -> PlayerSettingsState( doubleTapSeekEnabled = prefs.playerDoubleTapSeekEnabled, + doubleTapSeekSeconds = prefs.playerDoubleTapSeekSeconds, + preferredCodec = prefs.playerPreferredCodec, swipeSeekEnabled = prefs.playerSwipeSeekEnabled, swipeBrightnessVolumeEnabled = prefs.playerSwipeBrightnessVolumeEnabled, longPressSpeedEnabled = prefs.playerLongPressSpeedEnabled, + accessibleControlsEnabled = prefs.playerAccessibleControlsEnabled, autoplayEnabled = server.autoplay, autoplayCountdownSeconds = prefs.playerAutoplayCountdownSeconds, skipPlaylistAutoplayScreen = server.skipPlaylistAutoplayScreen, @@ -43,7 +47,7 @@ class PlayerSettingsViewModel @Inject constructor( danmakuEnabled = prefs.danmakuEnabled, danmakuSpeed = prefs.danmakuSpeed, danmakuSize = prefs.danmakuSize, - defaultQuality = server.defaultQuality, + defaultQuality = normalizeQualityName(server.defaultQuality), defaultPlaybackSpeed = server.defaultPlaybackSpeed, defaultService = server.defaultService, subtitlesEnabled = server.subtitlesEnabled, @@ -72,12 +76,18 @@ class PlayerSettingsViewModel @Inject constructor( when (action) { is PlayerSettingsAction.SetDoubleTapSeek -> preferencesRepository.setPlayerDoubleTapSeekEnabled(action.enabled) + is PlayerSettingsAction.SetDoubleTapSeekSeconds -> + preferencesRepository.setPlayerDoubleTapSeekSeconds(action.seconds) + is PlayerSettingsAction.SetPreferredCodec -> + preferencesRepository.setPlayerPreferredCodec(action.codec) is PlayerSettingsAction.SetSwipeSeek -> preferencesRepository.setPlayerSwipeSeekEnabled(action.enabled) is PlayerSettingsAction.SetSwipeBrightnessVolume -> preferencesRepository.setPlayerSwipeBrightnessVolumeEnabled(action.enabled) is PlayerSettingsAction.SetLongPressSpeed -> preferencesRepository.setPlayerLongPressSpeedEnabled(action.enabled) + is PlayerSettingsAction.SetAccessibleControls -> + preferencesRepository.setPlayerAccessibleControlsEnabled(action.enabled) is PlayerSettingsAction.SetPauseInBackground -> preferencesRepository.setPlayerPauseInBackground(action.enabled) is PlayerSettingsAction.SetDanmakuEnabled -> diff --git a/app/src/main/java/dev/typetype/android/feature/settings/player/SponsorBlockSettingsSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/player/SponsorBlockSettingsSection.kt index 57bfba52..966f6492 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/player/SponsorBlockSettingsSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/player/SponsorBlockSettingsSection.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -68,7 +67,11 @@ internal fun LazyListScope.sponsorBlockSettingsItems( }, ) } - items(SPONSOR_BLOCK_CATEGORIES.size, key = { "sponsor-${SPONSOR_BLOCK_CATEGORIES[it].key}" }) { + items( + SPONSOR_BLOCK_CATEGORIES.size, + key = { "sponsor-${SPONSOR_BLOCK_CATEGORIES[it].key}" }, + contentType = { "sponsor-category" }, + ) { val category = SPONSOR_BLOCK_CATEGORIES[it] DropdownRow( title = stringResource(category.labelResource), @@ -165,7 +168,7 @@ private fun SponsorBlockMinimumDurationRow( suffix = { Text(stringResource(R.string.settings_player_seconds_short)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true, - shape = RoundedCornerShape(10.dp), + shape = MaterialTheme.shapes.medium, ) } } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/privacy/ActiveSessionPrivacyCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/privacy/ActiveSessionPrivacyCard.kt index a9efe781..a129fe3e 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/privacy/ActiveSessionPrivacyCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/privacy/ActiveSessionPrivacyCard.kt @@ -1,13 +1,11 @@ package dev.typetype.android.feature.settings.privacy -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Devices import androidx.compose.material3.Icon @@ -17,7 +15,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -31,8 +28,6 @@ internal fun ActiveSessionPrivacyCard( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacyFailureCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacyFailureCard.kt index 122d1091..2fdca2cc 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacyFailureCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacyFailureCard.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Icon @@ -16,7 +15,6 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -32,8 +30,6 @@ internal fun PrivacyFailureCard( onDismiss: () -> Unit, ) { val title = when (action) { - PrivacyFailureAction.LoadSearchHistory -> - stringResource(R.string.settings_privacy_status_search_count) PrivacyFailureAction.LoadSubscriptions -> stringResource(R.string.settings_privacy_status_subscription_count) PrivacyFailureAction.LoadTrackingPreference -> @@ -45,7 +41,6 @@ internal fun PrivacyFailureCard( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(horizontal = 16.dp, vertical = 12.dp), ) { diff --git a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreen.kt index 950ebffd..c52572cc 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsScreen.kt @@ -1,6 +1,5 @@ package dev.typetype.android.feature.settings.privacy -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,10 +11,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -29,7 +28,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -126,13 +124,9 @@ internal fun PrivacySettingsScreen( item { PrivacyRow( title = stringResource(R.string.settings_privacy_search_history), - subtitle = pluralStringResource( - R.plurals.settings_privacy_entries_count, - state.searchHistoryCount, - state.searchHistoryCount, - ), + subtitle = stringResource(R.string.settings_privacy_search_history_description), actionLabel = stringResource(R.string.settings_privacy_clear), - actionEnabled = state.searchHistoryCount > 0, + actionEnabled = true, onAction = { pending = PendingAction.SearchHistory }, ) } @@ -212,8 +206,6 @@ private fun PrivacyRow( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -240,4 +232,5 @@ private fun PrivacyRow( ) } } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } diff --git a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsViewModel.kt index 81e49b49..d6495a0b 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/privacy/PrivacySettingsViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.launch data class PrivacyState( val historyCount: Int = 0, - val searchHistoryCount: Int = 0, val subscriptionsCount: Int = 0, val watchHistoryTrackingEnabled: Boolean = true, val watchHistoryTrackingControlEnabled: Boolean = false, @@ -35,7 +34,6 @@ data class PrivacyState( ) enum class PrivacyFailureAction { - LoadSearchHistory, LoadSubscriptions, LoadTrackingPreference, UpdateTrackingPreference, @@ -76,7 +74,6 @@ class PrivacySettingsViewModel @Inject constructor( private val _localState = MutableStateFlow(PrivacyState()) val combinedState = combine(state, _localState) { server, local -> server.copy( - searchHistoryCount = local.searchHistoryCount, watchHistoryTrackingControlEnabled = local.watchHistoryTrackingControlEnabled, errorMessage = local.errorMessage, errorRequestId = local.errorRequestId, @@ -85,7 +82,6 @@ class PrivacySettingsViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PrivacyState()) init { - refreshSearchHistory() refreshSubscriptions() refreshWatchHistoryTracking() } @@ -143,7 +139,6 @@ class PrivacySettingsViewModel @Inject constructor( viewModelScope.launch { clearFailure() searchHistoryRepository.clearHistory() - .onSuccess { _localState.update { it.copy(searchHistoryCount = 0) } } .onFailure { showFailure( it, @@ -179,7 +174,6 @@ class PrivacySettingsViewModel @Inject constructor( val action = _localState.value.failureAction clearFailure() when (action) { - PrivacyFailureAction.LoadSearchHistory -> refreshSearchHistory() PrivacyFailureAction.LoadSubscriptions -> refreshSubscriptions() PrivacyFailureAction.LoadTrackingPreference -> refreshWatchHistoryTracking() PrivacyFailureAction.UpdateTrackingPreference -> @@ -196,20 +190,6 @@ class PrivacySettingsViewModel @Inject constructor( clearFailure() } - private fun refreshSearchHistory() { - viewModelScope.launch { - searchHistoryRepository.loadHistory().onSuccess { history -> - _localState.update { it.copy(searchHistoryCount = history.size) } - }.onFailure { - showFailure( - it, - PrivacyFailureAction.LoadSearchHistory, - R.string.settings_privacy_load_search_failed, - ) - } - } - } - private fun refreshSubscriptions() { viewModelScope.launch { subscriptionsRepository.listSubscriptions() diff --git a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileAccountIdentitySection.kt b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileAccountIdentitySection.kt index e120b907..da9de1b7 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileAccountIdentitySection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileAccountIdentitySection.kt @@ -1,12 +1,10 @@ package dev.typetype.android.feature.settings.profile -import androidx.compose.foundation.background 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.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -16,7 +14,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -38,9 +35,7 @@ fun ProfileAccountIdentitySection( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + .padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( diff --git a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileCustomAvatarSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileCustomAvatarSection.kt index e4290ba2..5a00543d 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileCustomAvatarSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileCustomAvatarSection.kt @@ -1,12 +1,10 @@ package dev.typetype.android.feature.settings.profile -import androidx.compose.foundation.background 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.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -14,7 +12,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.typetype.android.R @@ -30,9 +27,7 @@ fun ProfileCustomAvatarSection( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + .padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( diff --git a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsAvatarSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsAvatarSection.kt index 28deac47..a4349eae 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsAvatarSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsAvatarSection.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -97,9 +96,7 @@ fun EmojiPicker( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(12.dp), + .padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -155,7 +152,7 @@ private fun EmojiSearchField( ) { Box( modifier = modifier - .clip(RoundedCornerShape(8.dp)) + .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant) .padding(horizontal = 8.dp, vertical = 6.dp), ) { @@ -202,7 +199,7 @@ private fun EmojiCell( Box( modifier = Modifier .size(48.dp) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background( if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) else MaterialTheme.colorScheme.surfaceVariant, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsFormSection.kt b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsFormSection.kt index d239df3b..b8427c30 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsFormSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsFormSection.kt @@ -1,19 +1,16 @@ package dev.typetype.android.feature.settings.profile -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -60,8 +57,6 @@ fun InfoRow(label: String, value: String) { Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsScreen.kt index 2b244010..f91028b6 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/profile/ProfileSettingsScreen.kt @@ -2,7 +2,6 @@ package dev.typetype.android.feature.settings.profile import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -31,12 +30,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.typetype.android.R @@ -199,9 +196,7 @@ private fun GuestProfileNotice() { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + .padding(vertical = 12.dp), ) } @@ -213,9 +208,7 @@ private fun ProfileLoadFailure( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), + .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( diff --git a/app/src/main/java/dev/typetype/android/feature/settings/rss/RssFeedsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/rss/RssFeedsScreen.kt index 3a9c6c1b..b36a9e1e 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/rss/RssFeedsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/rss/RssFeedsScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add @@ -25,7 +24,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Switch +import dev.typetype.android.core.ui.components.TypeTypeSwitch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -148,7 +147,7 @@ internal fun RssFeedsScreen( @Composable private fun RssLimitsCard(state: RssFeedsState) { - Surface(shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surface) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { Column( modifier = Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -181,7 +180,7 @@ private fun RssFeedCard( feed.name, stringResource(if (feed.enabled) R.string.rss_enabled else R.string.rss_disabled), ) - Surface(shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surface) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { Column(modifier = Modifier.fillMaxWidth().padding(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { @@ -204,7 +203,7 @@ private fun RssFeedCard( ) RssFeedSummary(feed) } - Switch( + TypeTypeSwitch( checked = feed.enabled, enabled = !isMutating, onCheckedChange = { onAction(RssFeedsAction.SetEnabled(feed.id, it)) }, @@ -239,7 +238,7 @@ private fun RssFeedCard( @Composable private fun RssEmptyCard() { - Surface(shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.surface) { + Surface(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface) { Text( text = stringResource(R.string.rss_empty), modifier = Modifier.fillMaxWidth().padding(20.dp), @@ -258,7 +257,7 @@ private fun RssFailureCard( ) { Column( modifier = Modifier.fillMaxWidth() - .background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.errorContainer, MaterialTheme.shapes.large) .padding(12.dp), ) { Text(message, color = MaterialTheme.colorScheme.onErrorContainer) diff --git a/app/src/main/java/dev/typetype/android/feature/settings/storage/StorageDownloadRow.kt b/app/src/main/java/dev/typetype/android/feature/settings/storage/StorageDownloadRow.kt index 4b3db92f..00bcfa66 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/storage/StorageDownloadRow.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/storage/StorageDownloadRow.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon @@ -44,7 +43,7 @@ fun StorageDownloadRow( Column( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(14.dp)) + .clip(MaterialTheme.shapes.large) .background(MaterialTheme.colorScheme.surface) .clickable(enabled = item.status == DownloadStatus.Successful, onClick = onOpen) .padding(horizontal = 16.dp, vertical = 12.dp), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeRemoteBrowserPane.kt b/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeRemoteBrowserPane.kt index cf015cdc..77ffa94f 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeRemoteBrowserPane.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeRemoteBrowserPane.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -73,7 +72,7 @@ internal fun YoutubeRemoteBrowserPane( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .background(Color.Black, RoundedCornerShape(14.dp)) + .background(Color.Black, MaterialTheme.shapes.large) .onSizeChanged { size -> containerSize = size remoteViewport(size).let { viewport -> diff --git a/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeSessionStatusCard.kt b/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeSessionStatusCard.kt index 0df929b8..cf96b659 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeSessionStatusCard.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/youtubesession/YoutubeSessionStatusCard.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -39,7 +38,7 @@ internal fun YoutubeSessionStatusCard( var confirmDisconnect by remember { mutableStateOf(false) } Surface( modifier = modifier, - shape = RoundedCornerShape(18.dp), + shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface, ) { Column( diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt index e133084f..c6f7a92e 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortPage.kt @@ -29,6 +29,8 @@ import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.components.VideoMenuAction import dev.typetype.android.core.ui.components.VideoMenuItemState +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.feature.player.ShortsPlaybackProgress import dev.typetype.android.feature.player.components.LocalMediaController @@ -61,6 +63,7 @@ internal fun ShortPage( embeddedPlayback: @Composable () -> Unit, ) { val mediaController = LocalMediaController.current + val serverBaseUrl = LocalServerBaseUrl.current val hapticFeedback = LocalHapticFeedback.current val currentMediaId = rememberCurrentMediaId(mediaController) val branding = rememberVideoBranding( @@ -98,7 +101,7 @@ internal fun ShortPage( }, ) { AsyncImage( - model = branding.thumbnailUrl, + model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsInfoOverlay.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsInfoOverlay.kt index fca54e07..b6732834 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsInfoOverlay.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsInfoOverlay.kt @@ -5,21 +5,23 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,6 +42,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video @Composable @@ -55,6 +59,7 @@ internal fun ShortsInfoOverlay( modifier: Modifier = Modifier, ) { val hapticFeedback = LocalHapticFeedback.current + val serverBaseUrl = LocalServerBaseUrl.current val titleCopyEnabled = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT val copyTitleLabel = stringResource(R.string.shorts_copy_title) val copyTitle = { @@ -75,7 +80,7 @@ internal fun ShortsInfoOverlay( .padding(vertical = 6.dp), ) { AsyncImage( - model = video.uploaderAvatarUrl, + model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(34.dp).clip(CircleShape), @@ -94,28 +99,35 @@ internal fun ShortsInfoOverlay( Button( onClick = onToggleSubscription, enabled = !subscriptionInFlight, - shape = RoundedCornerShape(6.dp), - modifier = Modifier.defaultMinSize(minHeight = 36.dp), + shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( containerColor = if (isSubscribed) { - Color.Black.copy(alpha = 0.68f) + Color.White.copy(alpha = 0.18f) } else { Color.White }, contentColor = if (isSubscribed) Color.White else Color.Black, + disabledContainerColor = Color.White.copy(alpha = 0.18f), + disabledContentColor = Color.White, ), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 12.dp, - vertical = 6.dp, - ), + contentPadding = PaddingValues(horizontal = 12.dp), + modifier = Modifier.height(36.dp), ) { - Text( - text = stringResource( - if (isSubscribed) R.string.channel_subscribed - else R.string.channel_subscribe, - ), - fontWeight = FontWeight.SemiBold, - ) + if (subscriptionInFlight) { + CircularProgressIndicator( + color = if (isSubscribed) Color.White else Color.Black, + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + } else { + Text( + text = stringResource( + if (isSubscribed) R.string.channel_subscribed + else R.string.channel_subscribe, + ), + fontWeight = FontWeight.SemiBold, + ) + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt index 63e72975..1d14e60c 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsRoute.kt @@ -142,7 +142,12 @@ fun ShortsRoute( if (playerHostController.state.value.videoUrl != video.url) { mediaController?.pause() } - playerHostController.openEmbeddedVideo(video.url, state.autoplayEnabled) + playerHostController.openEmbeddedVideo( + url = video.url, + autoplay = state.autoplayEnabled, + returnPositionMillis = mediaController?.currentPosition?.coerceAtLeast(0L), + returnPlayWhenReady = mediaController?.playWhenReady == true, + ) } }, onUpcomingVideosChanged = { videos -> diff --git a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionChannelsGrid.kt b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionChannelsGrid.kt index 7bdc1c35..53baadef 100644 --- a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionChannelsGrid.kt +++ b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionChannelsGrid.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import dev.typetype.android.R +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.subscriptions.SubscriptionSummary @Composable @@ -37,6 +39,7 @@ internal fun SubscriptionChannelsGrid( isLoading: Boolean, onOpenChannel: (String) -> Unit, ) { + val serverBaseUrl = LocalServerBaseUrl.current when { isLoading && channels.isEmpty() -> SubscriptionsLoadingGrid() channels.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -56,7 +59,7 @@ internal fun SubscriptionChannelsGrid( verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(channels, key = SubscriptionSummary::channelUrl) { channel -> - SubscriptionChannelCard(channel, onOpenChannel) + SubscriptionChannelCard(channel, serverBaseUrl, onOpenChannel) } } } @@ -65,6 +68,7 @@ internal fun SubscriptionChannelsGrid( @Composable private fun SubscriptionChannelCard( channel: SubscriptionSummary, + serverBaseUrl: String?, onOpenChannel: (String) -> Unit, ) { Surface( @@ -86,7 +90,7 @@ private fun SubscriptionChannelCard( ) { if (channel.avatarUrl.isNotBlank()) { AsyncImage( - model = channel.avatarUrl, + model = buildImageUrl(serverBaseUrl, channel.avatarUrl), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), diff --git a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsHeader.kt b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsHeader.kt index 618baeeb..2f5a2f79 100644 --- a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsHeader.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ExpandMore @@ -73,7 +72,7 @@ private fun SubscriptionsViewMenu( Box { OutlinedButton( onClick = { expanded = true }, - shape = RoundedCornerShape(12.dp), + shape = MaterialTheme.shapes.medium, modifier = Modifier.semantics { contentDescription = viewDescription stateDescription = selectedLabel diff --git a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt index 88865d9d..61dd74cb 100644 --- a/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt +++ b/app/src/main/java/dev/typetype/android/feature/subscriptions/SubscriptionsLoadingGrid.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -67,7 +66,7 @@ private fun LoadingCard(alpha: Float) { modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(12.dp)) + .clip(MaterialTheme.shapes.medium) .background(color), ) Spacer(Modifier.height(10.dp)) diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackLoadControl.kt b/app/src/main/java/dev/typetype/android/services/PlaybackLoadControl.kt index 5b36944f..e088e4a2 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackLoadControl.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackLoadControl.kt @@ -1,23 +1,56 @@ package dev.typetype.android.services +import android.app.ActivityManager import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl @UnstableApi -internal fun createPlaybackLoadControl(): DefaultLoadControl = +internal fun createPlaybackLoadControl(activityManager: ActivityManager): DefaultLoadControl = + createPlaybackLoadControl( + playbackBufferPolicy( + memoryClassMb = activityManager.memoryClass, + isLowRamDevice = activityManager.isLowRamDevice, + ), + ) + +@UnstableApi +internal fun createPlaybackLoadControl(policy: PlaybackBufferPolicy): DefaultLoadControl = DefaultLoadControl.Builder() .setBufferDurationsMs( - MIN_BUFFER_MS, - MAX_BUFFER_MS, - BUFFER_FOR_PLAYBACK_MS, - BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + policy.minBufferMs, + policy.maxBufferMs, + policy.bufferForPlaybackMs, + policy.bufferForPlaybackAfterRebufferMs, ) - .setPrioritizeTimeOverSizeThresholds(true) - .setBackBuffer(BACK_BUFFER_MS, true) + .setTargetBufferBytes(policy.targetBufferBytes) + .setPrioritizeTimeOverSizeThresholds(false) + .setBackBuffer(policy.backBufferMs, true) .build() -private const val MIN_BUFFER_MS = 30_000 -private const val MAX_BUFFER_MS = 30_000 -private const val BUFFER_FOR_PLAYBACK_MS = 2_000 -private const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 3_000 -private const val BACK_BUFFER_MS = 30_000 +internal fun playbackBufferPolicy( + memoryClassMb: Int, + isLowRamDevice: Boolean, +): PlaybackBufferPolicy { + val targetBufferBytes = when { + isLowRamDevice || memoryClassMb < MEMORY_CLASS_128_MB -> LOW_RAM_TARGET_BUFFER_BYTES + memoryClassMb < MEMORY_CLASS_192_MB -> MEDIUM_HEAP_TARGET_BUFFER_BYTES + else -> LARGE_HEAP_TARGET_BUFFER_BYTES + } + return PlaybackBufferPolicy(targetBufferBytes = targetBufferBytes) +} + +internal data class PlaybackBufferPolicy( + val minBufferMs: Int = 15_000, + val maxBufferMs: Int = 30_000, + val bufferForPlaybackMs: Int = 2_000, + val bufferForPlaybackAfterRebufferMs: Int = 3_000, + val backBufferMs: Int = 15_000, + val targetBufferBytes: Int, +) + +private const val MEMORY_CLASS_128_MB = 128 +private const val MEMORY_CLASS_192_MB = 192 +private const val MEBIBYTE = 1024 * 1024 +private const val LOW_RAM_TARGET_BUFFER_BYTES = 32 * MEBIBYTE +private const val MEDIUM_HEAP_TARGET_BUFFER_BYTES = 64 * MEBIBYTE +private const val LARGE_HEAP_TARGET_BUFFER_BYTES = 96 * MEBIBYTE diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackRenderersFactory.kt b/app/src/main/java/dev/typetype/android/services/PlaybackRenderersFactory.kt new file mode 100644 index 00000000..6d9a23c1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/services/PlaybackRenderersFactory.kt @@ -0,0 +1,21 @@ +package dev.typetype.android.services + +import android.content.Context +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink + +@UnstableApi +internal class PlaybackRenderersFactory( + context: Context, +) : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams) + .build() +} diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackService.kt b/app/src/main/java/dev/typetype/android/services/PlaybackService.kt index 06f55492..01063e45 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackService.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackService.kt @@ -1,5 +1,6 @@ package dev.typetype.android.services +import android.app.ActivityManager import android.app.PendingIntent import android.content.Intent import dagger.hilt.android.AndroidEntryPoint @@ -7,7 +8,6 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy import androidx.media3.session.CommandButton @@ -168,17 +168,18 @@ class PlaybackService : MediaSessionService() { playbackClock: SabrPlaybackClock, recoveryDispatcher: SabrPlaybackRecoveryDispatcher, ): ExoPlayer { + val activityManager = requireNotNull(getSystemService(ActivityManager::class.java)) val audioAttributes = AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .build() - val renderersFactory = DefaultRenderersFactory(this) + val renderersFactory = PlaybackRenderersFactory(this) .setEnableDecoderFallback(true) return ExoPlayer.Builder(this, renderersFactory) .setAudioAttributes(audioAttributes, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) - .setLoadControl(createPlaybackLoadControl()) + .setLoadControl(createPlaybackLoadControl(activityManager)) .setMediaSourceFactory( MergedStreamMediaSourceFactory( this, diff --git a/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowCache.kt b/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowCache.kt index 559dac69..3239ffc0 100644 --- a/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowCache.kt +++ b/app/src/main/java/dev/typetype/android/services/SabrPlaybackWindowCache.kt @@ -3,21 +3,23 @@ package dev.typetype.android.services import dev.typetype.android.domain.stream.SabrPlaybackBinding import dev.typetype.android.domain.stream.SabrPlaybackSession import dev.typetype.android.domain.stream.binding -import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @Singleton class SabrPlaybackWindowCache @Inject constructor() { - private val sessions = ConcurrentHashMap() + private val sessions = LinkedHashMap(MAX_SESSIONS, 0.75f, true) + @Synchronized fun put(session: SabrPlaybackSession) { if (session.audioWindow?.segments.isNullOrEmpty()) return if (session.videoWindow != null && session.videoWindow.segments.isEmpty()) return sessions[session.binding.toKey()] = session sessions.keys.removeIf { it.sessionId == session.sessionId && it.generation != session.generation } + while (sessions.size > MAX_SESSIONS) sessions.remove(sessions.keys.first()) } + @Synchronized fun take(binding: SabrPlaybackBinding): SabrPlaybackSession? = sessions.remove(binding.toKey()) @@ -36,4 +38,8 @@ class SabrPlaybackWindowCache @Inject constructor() { val audioItag: Int, val audioTrackId: String?, ) + + private companion object { + const val MAX_SESSIONS = 4 + } } diff --git a/app/src/main/res/values/languages.xml b/app/src/main/res/values/languages.xml new file mode 100644 index 00000000..5b59bf2c --- /dev/null +++ b/app/src/main/res/values/languages.xml @@ -0,0 +1,6 @@ + + + + en + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 31c04864..c05d5139 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -63,6 +63,8 @@ Title copied Rewind Play / pause + Play + Pause Up next in %1$d second Up next in %1$d seconds @@ -80,6 +82,8 @@ Paused Forward Fullscreen + Playback position + %1$s of %2$s Back Playback options Codec @@ -90,8 +94,13 @@ Playback speed Auto Recommended + Smart + AV1 + VP9 + H.264 Adapts quality to your connection Chooses the best compatible device format + Prefers efficient hardware decoding and falls back safely Hardware support detected Software support only %1$s (%2$s) @@ -100,6 +109,7 @@ Original Auto-generated Up next + Show more recommendations Chapters Resize Original @@ -108,6 +118,8 @@ Brightness Volume Close mini player + Expand player + Send playback to the background Restoring playback… Playback could not be restored Picture in picture @@ -135,6 +147,52 @@ Appearance Theme and accent color Accent color + System + Style + Color theme + TypeType + TypeType colors with your selected accent + Dynamic + Use Android system colors when available + Nord + Cream + Forest + Plum + Classic + A calm, rounded Material interface + Manga + Strong ink, paper, and graphic details + Color mode + Follow system + Light + Dark + AMOLED black + Use pure black surfaces in dark mode + Manga paper + Day paper + Night paper + Nord paper + Manga details + Plain headings + Stamped headings + Speed-line headings + Screentone texture + Speed lines + Starburst highlights + Inked icons + Tilted panels + Typography + System + Use the device typeface + Expressive + Editorial headings with readable body text + Motion + Full + Subtle + Off + TypeType Classic + TypeType Manga + Your appearance updates instantly across the app. Content Startup Default landing page @@ -142,10 +200,6 @@ Discovery, DeArrow, and visible surfaces YouTube Visible surfaces - Hide everything - Hide recommendations, comments, danmaku, and Shorts at once - Hide every content surface? - You can restore every surface with this switch or enable them individually later. DeArrow titles and thumbnails Use community-written titles and representative thumbnails Titles @@ -217,10 +271,17 @@ Video up to %1$dp Audio only About + Open source licenses + Third-party notice + Komi Store + Appearance and theme patterns are inspired by Komi Store, licensed under Apache-2.0. Version and credits Gestures + Accessible player controls + Keep controls visible and disable gesture-only interactions. Enabled automatically with screen readers. Double-tap to seek - Double-tap left or right to seek 10 seconds + Double-tap left or right to seek, or center to play and pause + Double-tap seek time Swipe to seek Horizontal drag scrubs the timeline Brightness and volume swipe @@ -228,7 +289,11 @@ Long-press to speed up Hold to play at 2x, release to resume Playback + Show advanced player settings + Hide advanced player settings Default quality + Default codec + Smart prefers efficient hardware decoding Preferred video resolution Default playback speed Applied when a video starts @@ -299,6 +364,7 @@ TypeType request ID Request ID copied Request %1$s + Code %1$s App stopped unexpectedly App stopped responding System stopped the app for memory @@ -360,6 +426,7 @@ Watch history tracking Save watched videos and playback progress Search history + Clear saved searches from this account Subscriptions Active sessions While a member account is active, the app reports a random installation ID and current non-live playback to the selected TypeType instance. It does not send a hardware ID, account email, network name or private host, and reporting failures never interrupt playback. @@ -379,10 +446,8 @@ Clear search history? Unsubscribe from all channels? This action cannot be undone. - Couldn\'t refresh your search history count Couldn\'t refresh your subscription count Couldn\'t load the watch history preference - Search history count unavailable Subscription count unavailable Watch history setting unavailable Change not completed @@ -473,6 +538,85 @@ Couldn\'t load the account identity Data and backups Export or restore TypeType data, or import from YouTube and PipePipe + Export + Import + Data portability + Move account data into TypeType or create a backup for another supported app. + Destination format + Import from + Generate export + Download export + New export + Download report + Start another export + Start another import + Choose or drop a backup file + Choose another backup + duplicate records + Different file + Compatibility notes + Export notes + Duplicate policy + Select all + Skip + Replace + Import selected + Job + Request ID + Code + Waiting + Analyzing backup + Ready to import + Importing + Generating export + Import complete + Export ready + Cancelled + Failed + The operation failed. + Copy diagnostic reference + Copied + Cancel + Export a portable backup + Choose the data and destination format. + Restore an external backup + TypeType detects the format and shows a preview first. + No export formats are available on this instance. + No import formats are available on this instance. + This destination cannot represent every TypeType field. The completed report will list any data that could not be preserved. + Loading supported formats + Get your %1$s backup + Move data from another TypeType instance or restore a portable backup. + Open Settings, then Data portability on the source instance. + Choose TypeType as the destination and download the export. + Drop the downloaded backup below without changing it. + Google Takeout can provide your subscriptions, history, playlists, Watch later and liked videos. + Open Google Takeout with YouTube and My Activity selected. + Create a one-time ZIP export, then download it from Google. + Drop the original ZIP below. If Google creates several ZIPs, import them one at a time. + Open Google Takeout + Move subscriptions from any app or service that can export an OPML file. + Find the subscription export or backup action in the source app. + Choose OPML when the app asks for an export format. + Download the OPML file, then drop it below. + Bring your existing %1$s data into TypeType. + Open %1$s and find its backup or export action. + Download the original backup file without editing or extracting it. + Drop the file below. TypeType will preview the data before anything is imported. + Channel and feed records. + Organized channel groups. + Previously watched videos. + User-created playlists. + Videos saved for later. + Favorite videos. + Resume positions. + Saved search terms. + Links to external playlists. + App preferences. + Blocked videos, channels, and words. + Backup and restore + Takeout archive + Backup ZIP TypeType backup Choose what to include in a portable TypeType JSON backup. Export selected data @@ -503,6 +647,7 @@ Import another backup History Subscriptions + Subscription groups Playlists Playlist videos Watch progress @@ -556,7 +701,6 @@ License GPL v3 Server - Links Versions Refresh versions Frontend @@ -567,8 +711,6 @@ Unavailable Server URL Signed in as - TypeType (server) - TypeType-Android Autoplay related videos Autoplay countdown Time before the next video starts @@ -614,7 +756,6 @@ SponsorBlock segment TypeType Version - Inspired by LibreTube and PipePipe Client. Built on TypeType-Server. Search videos… All Content type diff --git a/app/src/release/generated/baselineProfiles/baseline-prof.txt b/app/src/release/generated/baselineProfiles/baseline-prof.txt index a9803147..1f7f6c1f 100644 --- a/app/src/release/generated/baselineProfiles/baseline-prof.txt +++ b/app/src/release/generated/baselineProfiles/baseline-prof.txt @@ -1,282 +1,274 @@ Ldev/typetype/android/AppNavHostKt; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$Opixs-rPPp5YQUdJQMg2f48hjE8(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$_0KcKXCk91jQECPRLCbrRIcn_7Q(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$u0D_BG7lzJw1YeTGCBGtQMhsGN4(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$xKqHHdO_kj_9Hnw8_qMoWOzlFZE(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$1(Landroidx/compose/runtime/State;)Ljava/lang/String; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$0$0(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$1$0(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$4$0(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$2(Landroidx/compose/runtime/State;)Ljava/lang/String; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$3(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/profile/Profile; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$4(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/notifications/NotificationBadge; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$5(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/usersettings/UserSettings; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Landroidx/compose/runtime/Composer;I)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda16; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda16;->(Ldev/typetype/android/feature/player/host/PlayerHostController;)V +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$NyFgRchAqiakOVF1Puyumwqk2T8(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$b2alhOikyGAM-802JN6GbAnRO5w(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$l15Vjuk1R18hhD2H4cU8sRvU7w8(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$vCO0rzyOyfRjyWDqCLvNK-_bi94(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$1(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/server/Server; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$0$0(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$1$0(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$4$0(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;ILdev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$2(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/profile/Profile; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$3(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$4(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/notifications/NotificationBadge; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$5(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/usersettings/UserSettings; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Landroidx/compose/runtime/Composer;I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda1; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda10; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda10;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda12; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda12;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->(I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->(I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19;->(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19;->(I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda2; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda20; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda20;->(I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda24; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda24;->(Ldev/typetype/android/feature/player/host/PlayerHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25;->(Lkotlin/jvm/functions/Function1;Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;ILdev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda28; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda28;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda29; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda29;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda30; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda30;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda31; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda31;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda32; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda32;->(Lkotlin/jvm/functions/Function1;Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda34; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda34;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function1;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda35; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda35;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda36; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda36;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27;->(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda3; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda4; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda40; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda40;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda41; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda41;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda43; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda43;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda45; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda45;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda46; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda46;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda49; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda49;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->()V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->()V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7;->()V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8;->()V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->(Ldev/typetype/android/MainViewModel;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->(Ldev/typetype/android/MainViewModel;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$AppNavHost$1$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1$1;->(Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1$1;->(Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$2$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$2$1$1;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$2$1$1;->(Ljava/lang/Object;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6;->(Ljava/lang/Object;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1;->(Ljava/lang/Object;)V +Ldev/typetype/android/AppNavHostKt$WhenMappings; +HSPLdev/typetype/android/AppNavHostKt$WhenMappings;->()V Ldev/typetype/android/AppShellKt; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$1Zq5J--ziI-P3_sH2qn_Y6u3M6g(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$GCNNvbojsw2GG-Mxj3FMwf8V8Nw(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$QtPzzCSx89RPIRJHAhr1vSIJjgY(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$jQKMiga4IUlBWow7YYLA3VjmXgQ(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$mAa56vnHhFW2GGbqnlQVy8qCM2c()Landroidx/compose/runtime/MutableState; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$pn_MOdMzNrF_a7WCKRQi8L3dbCw(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->()V -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$11(Landroidx/compose/runtime/State;)Ldev/typetype/android/feature/player/host/PlayerHostStateSnapshot; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$0(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$1(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$2(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$3(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$15(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;IIILandroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$3(Landroidx/compose/runtime/State;)Landroidx/navigation/NavBackStackEntry; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$5$0()Landroidx/compose/runtime/MutableState; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$9(Landroidx/compose/runtime/MutableState;)Z -SPLdev/typetype/android/AppShellKt;->AppShell(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -SPLdev/typetype/android/AppShellKt;->isAppChromeVisible(Ldev/typetype/android/feature/player/host/PlayerHostTarget;Z)Z +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$9KfNpz2UfznzNA-tHUDzlBc49rA(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$P6UaQzfGI2fexntsRNk562uly70(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZLandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$QuVdSyAbKLIsWTghRqf_tfWf4Hs(Landroidx/compose/runtime/MutableFloatState;F)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$i7ED_rJGMFDg-flVv1UQeGse0bc(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$mAa56vnHhFW2GGbqnlQVy8qCM2c()Landroidx/compose/runtime/MutableState; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$uC41w5yw2wKrjVk_m15a2onBT88(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$yuIkXZ5Dpa9rv5anMongZJ7vOrw(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->()V +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$12(Landroidx/compose/runtime/MutableFloatState;)F +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$13(Landroidx/compose/runtime/MutableFloatState;F)V +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$14(Landroidx/compose/runtime/State;)Ldev/typetype/android/feature/player/host/PlayerHostStateSnapshot; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$0(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZLandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$1(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$2(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$3(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$5$0(Landroidx/compose/runtime/MutableFloatState;F)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$18(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;IIILandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$3(Landroidx/compose/runtime/State;)Landroidx/navigation/NavBackStackEntry; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$5$0()Landroidx/compose/runtime/MutableState; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$9(Landroidx/compose/runtime/MutableState;)Z +HSPLdev/typetype/android/AppShellKt;->AppShell(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +HSPLdev/typetype/android/AppShellKt;->isAppChromeVisible(Ldev/typetype/android/feature/player/host/PlayerHostTarget;Z)Z +HSPLdev/typetype/android/AppShellKt;->playerPhoneChromeAlpha(ZLdev/typetype/android/feature/player/host/PlayerHostTarget;ZF)F +HSPLdev/typetype/android/AppShellKt;->shouldReportPlayerProgress(FF)Z +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda10; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda10;->(Landroidx/compose/material3/SnackbarHostState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda10;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda11; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda11;->(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda11;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda12; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda2; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda2;->()V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda2;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->(Landroidx/compose/runtime/MutableState;)V +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda13; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda13;->(Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda13;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda14; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda14;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda14;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda3; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->()V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->invoke()Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda4; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;III)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda6; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda6;->(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda6;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda7; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda7;->(Landroidx/compose/material3/SnackbarHostState;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda7;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;III)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda8; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZ)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda9; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->(Landroidx/compose/runtime/MutableState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$AppShell$4$1; -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->(Landroidx/navigation/NavDestination;Landroidx/compose/runtime/MutableState;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->(Landroidx/navigation/NavDestination;Landroidx/compose/runtime/MutableState;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/DefaultLandingRouteKt; +HSPLdev/typetype/android/DefaultLandingRouteKt;->startupRoute(Ljava/lang/String;Ljava/lang/String;Ldev/typetype/android/domain/auth/SessionStatus;Ljava/lang/String;)Ljava/lang/Object; Ldev/typetype/android/Hilt_MainActivity; -SPLdev/typetype/android/Hilt_MainActivity;->()V -SPLdev/typetype/android/Hilt_MainActivity;->_initHiltInternal()V -SPLdev/typetype/android/Hilt_MainActivity;->componentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; -SPLdev/typetype/android/Hilt_MainActivity;->createComponentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; -SPLdev/typetype/android/Hilt_MainActivity;->generatedComponent()Ljava/lang/Object; -SPLdev/typetype/android/Hilt_MainActivity;->getDefaultViewModelProviderFactory()Landroidx/lifecycle/ViewModelProvider$Factory; -SPLdev/typetype/android/Hilt_MainActivity;->initSavedStateHandleHolders()V -SPLdev/typetype/android/Hilt_MainActivity;->inject()V -SPLdev/typetype/android/Hilt_MainActivity;->onCreate(Landroid/os/Bundle;)V +HSPLdev/typetype/android/Hilt_MainActivity;->()V +HSPLdev/typetype/android/Hilt_MainActivity;->_initHiltInternal()V +HSPLdev/typetype/android/Hilt_MainActivity;->componentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; +HSPLdev/typetype/android/Hilt_MainActivity;->createComponentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; +HSPLdev/typetype/android/Hilt_MainActivity;->generatedComponent()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_MainActivity;->getDefaultViewModelProviderFactory()Landroidx/lifecycle/ViewModelProvider$Factory; +HSPLdev/typetype/android/Hilt_MainActivity;->initSavedStateHandleHolders()V +HSPLdev/typetype/android/Hilt_MainActivity;->inject()V +HSPLdev/typetype/android/Hilt_MainActivity;->onCreate(Landroid/os/Bundle;)V Ldev/typetype/android/Hilt_MainActivity$1; -SPLdev/typetype/android/Hilt_MainActivity$1;->(Ldev/typetype/android/Hilt_MainActivity;)V -SPLdev/typetype/android/Hilt_MainActivity$1;->onContextAvailable(Landroid/content/Context;)V +HSPLdev/typetype/android/Hilt_MainActivity$1;->(Ldev/typetype/android/Hilt_MainActivity;)V +HSPLdev/typetype/android/Hilt_MainActivity$1;->onContextAvailable(Landroid/content/Context;)V Ldev/typetype/android/Hilt_TypeTypeApp; -SPLdev/typetype/android/Hilt_TypeTypeApp;->()V -SPLdev/typetype/android/Hilt_TypeTypeApp;->componentManager()Ldagger/hilt/android/internal/managers/ApplicationComponentManager; -SPLdev/typetype/android/Hilt_TypeTypeApp;->generatedComponent()Ljava/lang/Object; -SPLdev/typetype/android/Hilt_TypeTypeApp;->hiltInternalInject()V -SPLdev/typetype/android/Hilt_TypeTypeApp;->onCreate()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->componentManager()Ldagger/hilt/android/internal/managers/ApplicationComponentManager; +HSPLdev/typetype/android/Hilt_TypeTypeApp;->generatedComponent()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_TypeTypeApp;->hiltInternalInject()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->onCreate()V Ldev/typetype/android/Hilt_TypeTypeApp$1; -SPLdev/typetype/android/Hilt_TypeTypeApp$1;->(Ldev/typetype/android/Hilt_TypeTypeApp;)V -SPLdev/typetype/android/Hilt_TypeTypeApp$1;->get()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_TypeTypeApp$1;->(Ldev/typetype/android/Hilt_TypeTypeApp;)V +HSPLdev/typetype/android/Hilt_TypeTypeApp$1;->get()Ljava/lang/Object; Ldev/typetype/android/MainActivity; -SPLdev/typetype/android/MainActivity;->$r8$lambda$bUlwZs3rId2DqH0MjONKMRSubBA(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->()V -SPLdev/typetype/android/MainActivity;->()V -SPLdev/typetype/android/MainActivity;->getActiveSessionRepository()Ldev/typetype/android/domain/session/ActiveSessionRepository; -SPLdev/typetype/android/MainActivity;->getViewModel()Ldev/typetype/android/MainViewModel; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$0(Ldev/typetype/android/MainActivity;)Z -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/MainState; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->onCreate(Landroid/os/Bundle;)V -SPLdev/typetype/android/MainActivity;->onStart()V -SPLdev/typetype/android/MainActivity;->receiveExternalVideo(Landroid/content/Intent;)V -SPLdev/typetype/android/MainActivity;->receiveOidcCallback(Landroid/content/Intent;)V -SPLdev/typetype/android/MainActivity;->registerPipReceiver()V +HSPLdev/typetype/android/MainActivity;->$r8$lambda$bUlwZs3rId2DqH0MjONKMRSubBA(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->()V +HSPLdev/typetype/android/MainActivity;->()V +HSPLdev/typetype/android/MainActivity;->getActiveSessionRepository()Ldev/typetype/android/domain/session/ActiveSessionRepository; +HSPLdev/typetype/android/MainActivity;->getViewModel()Ldev/typetype/android/MainViewModel; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$0(Ldev/typetype/android/MainActivity;)Z +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/MainState; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->onCreate(Landroid/os/Bundle;)V +HSPLdev/typetype/android/MainActivity;->onStart()V +HSPLdev/typetype/android/MainActivity;->receiveExternalVideo(Landroid/content/Intent;)V +HSPLdev/typetype/android/MainActivity;->receiveOidcCallback(Landroid/content/Intent;)V +HSPLdev/typetype/android/MainActivity;->registerPipReceiver()V Ldev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/app/Activity;)Z -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/net/ConnectivityManager;Landroid/net/ConnectivityManager$NetworkCallback;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/WindowManager$LayoutParams;I)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/app/Activity;)Z +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/net/ConnectivityManager;Landroid/net/ConnectivityManager$NetworkCallback;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/WindowManager$LayoutParams;I)V Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda4; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->shouldKeepOnScreen()Z +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->shouldKeepOnScreen()Z Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda5; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda8; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$Companion; -SPLdev/typetype/android/MainActivity$Companion;->()V -SPLdev/typetype/android/MainActivity$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLdev/typetype/android/MainActivity$Companion;->()V +HSPLdev/typetype/android/MainActivity$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Ldev/typetype/android/MainActivity$onStart$1; -SPLdev/typetype/android/MainActivity$onStart$1;->(Ldev/typetype/android/MainActivity;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainActivity$onStart$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainActivity$onStart$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$onStart$1;->(Ldev/typetype/android/MainActivity;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainActivity$onStart$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainActivity$onStart$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$pipReceiver$1; -SPLdev/typetype/android/MainActivity$pipReceiver$1;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$pipReceiver$1;->(Ldev/typetype/android/MainActivity;)V Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$1; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->(Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Landroidx/lifecycle/ViewModelProvider$Factory; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->(Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Landroidx/lifecycle/ViewModelProvider$Factory; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$2; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->(Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Landroidx/lifecycle/ViewModelStore; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->(Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Landroidx/lifecycle/ViewModelStore; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$3; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->(Lkotlin/jvm/functions/Function0;Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Landroidx/lifecycle/viewmodel/CreationExtras; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->(Lkotlin/jvm/functions/Function0;Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Landroidx/lifecycle/viewmodel/CreationExtras; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainViewModel; -SPLdev/typetype/android/MainViewModel;->()V -SPLdev/typetype/android/MainViewModel;->(Ldev/typetype/android/domain/server/ServerRepository;Ldev/typetype/android/data/network/AccessTokenStore;Ldev/typetype/android/domain/auth/AuthRepository;Ldev/typetype/android/domain/actions/VideoActionsRepository;Ldev/typetype/android/domain/usersettings/UserSettingsRepository;Ldev/typetype/android/domain/profile/ProfileRepository;Ldev/typetype/android/domain/subscriptions/SubscriptionsRepository;Ldev/typetype/android/domain/library/LibraryRepository;Ldev/typetype/android/data/account/ActiveAccountScope;Ldev/typetype/android/domain/playback/PlaybackResumeRepository;Ldev/typetype/android/domain/playback/PlaybackQueueRepository;Ldev/typetype/android/domain/diagnostics/CrashReportRepository;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/domain/preferences/PreferencesRepository;)V -SPLdev/typetype/android/MainViewModel;->access$getCrashReportRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/diagnostics/CrashReportRepository; -SPLdev/typetype/android/MainViewModel;->access$getServerRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/server/ServerRepository; -SPLdev/typetype/android/MainViewModel;->access$get_state$p(Ldev/typetype/android/MainViewModel;)Lkotlinx/coroutines/flow/MutableStateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentProfile()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentServerBaseUrl()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentServerId()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getEvents()Lkotlinx/coroutines/flow/Flow; -SPLdev/typetype/android/MainViewModel;->getPlayerHostController()Ldev/typetype/android/feature/player/host/PlayerHostController; -SPLdev/typetype/android/MainViewModel;->getPreferences()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getState()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->()V +HSPLdev/typetype/android/MainViewModel;->(Ldev/typetype/android/domain/server/ServerRepository;Ldev/typetype/android/data/network/AccessTokenStore;Ldev/typetype/android/domain/auth/AuthRepository;Ldev/typetype/android/domain/actions/VideoActionsRepository;Ldev/typetype/android/domain/usersettings/UserSettingsRepository;Ldev/typetype/android/domain/profile/ProfileRepository;Ldev/typetype/android/domain/subscriptions/SubscriptionsRepository;Ldev/typetype/android/domain/library/LibraryRepository;Ldev/typetype/android/data/account/ActiveAccountScope;Ldev/typetype/android/data/preferences/StartupLandingStore;Ldev/typetype/android/domain/playback/PlaybackResumeRepository;Ldev/typetype/android/domain/playback/PlaybackQueueRepository;Ldev/typetype/android/domain/diagnostics/CrashReportRepository;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/domain/preferences/PreferencesRepository;)V +HSPLdev/typetype/android/MainViewModel;->access$getCrashReportRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/diagnostics/CrashReportRepository; +HSPLdev/typetype/android/MainViewModel;->access$getServerRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/server/ServerRepository; +HSPLdev/typetype/android/MainViewModel;->access$get_state$p(Ldev/typetype/android/MainViewModel;)Lkotlinx/coroutines/flow/MutableStateFlow; +HSPLdev/typetype/android/MainViewModel;->getCurrentProfile()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getCurrentServer()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getEvents()Lkotlinx/coroutines/flow/Flow; +HSPLdev/typetype/android/MainViewModel;->getPlayerHostController()Ldev/typetype/android/feature/player/host/PlayerHostController; +HSPLdev/typetype/android/MainViewModel;->getPreferences()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getState()Lkotlinx/coroutines/flow/StateFlow; Ldev/typetype/android/MainViewModel$1; -SPLdev/typetype/android/MainViewModel$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainViewModel$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainViewModel$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainViewModel$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainViewModel$1$initial$1; -SPLdev/typetype/android/MainViewModel$1$initial$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainViewModel$1$initial$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$1$initial$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainViewModel$Companion; -SPLdev/typetype/android/MainViewModel$Companion;->()V -SPLdev/typetype/android/MainViewModel$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1;->collect(Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$1;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2;->(Lkotlinx/coroutines/flow/FlowCollector;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2;->(Lkotlinx/coroutines/flow/Flow;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2;->collect(Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$2;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2;->(Lkotlinx/coroutines/flow/FlowCollector;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$Companion;->()V +HSPLdev/typetype/android/MainViewModel$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Ldev/typetype/android/SetupNavigationKt; -SPLdev/typetype/android/SetupNavigationKt;->setupDestinations$lambda$0(Landroidx/navigation/NavHostController;Landroidx/compose/animation/AnimatedContentScope;Landroidx/navigation/NavBackStackEntry;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/SetupNavigationKt;->setupDestinations(Landroidx/navigation/NavGraphBuilder;Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt;->setupDestinations$lambda$0(Landroidx/navigation/NavHostController;Landroidx/compose/animation/AnimatedContentScope;Landroidx/navigation/NavBackStackEntry;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/SetupNavigationKt;->setupDestinations(Landroidx/navigation/NavGraphBuilder;Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/TypeTypeApp; -SPLdev/typetype/android/TypeTypeApp;->()V -SPLdev/typetype/android/TypeTypeApp;->()V -SPLdev/typetype/android/TypeTypeApp;->getLifecycleDiagnostics()Ldev/typetype/android/data/diagnostics/ApplicationLifecycleDiagnostics; -SPLdev/typetype/android/TypeTypeApp;->getWorkManagerConfiguration()Landroidx/work/Configuration; -SPLdev/typetype/android/TypeTypeApp;->getWorkerFactory()Landroidx/hilt/work/HiltWorkerFactory; -SPLdev/typetype/android/TypeTypeApp;->newImageLoader(Landroid/content/Context;)Lcoil3/ImageLoader; -SPLdev/typetype/android/TypeTypeApp;->onCreate()V \ No newline at end of file +HSPLdev/typetype/android/TypeTypeApp;->()V +HSPLdev/typetype/android/TypeTypeApp;->()V +HSPLdev/typetype/android/TypeTypeApp;->getLifecycleDiagnostics()Ldev/typetype/android/data/diagnostics/ApplicationLifecycleDiagnostics; +HSPLdev/typetype/android/TypeTypeApp;->getWorkManagerConfiguration()Landroidx/work/Configuration; +HSPLdev/typetype/android/TypeTypeApp;->getWorkerFactory()Landroidx/hilt/work/HiltWorkerFactory; +HSPLdev/typetype/android/TypeTypeApp;->newImageLoader(Landroid/content/Context;)Lcoil3/ImageLoader; +HSPLdev/typetype/android/TypeTypeApp;->onCreate()V \ No newline at end of file diff --git a/app/src/release/generated/baselineProfiles/startup-prof.txt b/app/src/release/generated/baselineProfiles/startup-prof.txt index a9803147..1f7f6c1f 100644 --- a/app/src/release/generated/baselineProfiles/startup-prof.txt +++ b/app/src/release/generated/baselineProfiles/startup-prof.txt @@ -1,282 +1,274 @@ Ldev/typetype/android/AppNavHostKt; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$Opixs-rPPp5YQUdJQMg2f48hjE8(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$_0KcKXCk91jQECPRLCbrRIcn_7Q(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$u0D_BG7lzJw1YeTGCBGtQMhsGN4(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; -SPLdev/typetype/android/AppNavHostKt;->$r8$lambda$xKqHHdO_kj_9Hnw8_qMoWOzlFZE(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$1(Landroidx/compose/runtime/State;)Ljava/lang/String; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$0$0(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$1$0(Landroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$4$0(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$2(Landroidx/compose/runtime/State;)Ljava/lang/String; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$3(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/profile/Profile; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$4(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/notifications/NotificationBadge; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$5(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/usersettings/UserSettings; -SPLdev/typetype/android/AppNavHostKt;->AppNavHost(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Landroidx/compose/runtime/Composer;I)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda16; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda16;->(Ldev/typetype/android/feature/player/host/PlayerHostController;)V +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$NyFgRchAqiakOVF1Puyumwqk2T8(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$b2alhOikyGAM-802JN6GbAnRO5w(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$l15Vjuk1R18hhD2H4cU8sRvU7w8(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->$r8$lambda$vCO0rzyOyfRjyWDqCLvNK-_bi94(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$1(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/server/Server; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$0$0(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/EnterTransition; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$1$0(ILandroidx/compose/animation/AnimatedContentTransitionScope;)Landroidx/compose/animation/ExitTransition; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6$4$0(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/navigation/NavGraphBuilder;)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11$6(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$11(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;ILdev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$2(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/profile/Profile; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$3(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$4(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/notifications/NotificationBadge; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost$lambda$5(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/usersettings/UserSettings; +HSPLdev/typetype/android/AppNavHostKt;->AppNavHost(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Landroidx/compose/runtime/Composer;I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda1; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda10; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda10;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda12; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda12;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->(I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda17;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->(I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda18;->invoke(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19;->(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;I)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda19;->(I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda2; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda20; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda20;->(I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda21;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda24; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda24;->(Ldev/typetype/android/feature/player/host/PlayerHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25;->(Lkotlin/jvm/functions/Function1;Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda25;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->(Ldev/typetype/android/MainViewModel;Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;Ljava/lang/Object;ILdev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda26;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda28; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda28;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda29; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda29;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda30; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda30;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda31; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda31;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda32; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda32;->(Lkotlin/jvm/functions/Function1;Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda34; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda34;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function1;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda35; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda35;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda36; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda36;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda27;->(Ljava/lang/Object;Ldev/typetype/android/MainViewModel;I)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda3; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda37;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda38;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda39;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda4; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda40; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda40;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda41; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda41;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda43; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda43;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda45; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda45;->(Landroidx/navigation/NavHostController;)V -Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda46; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda46;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->(Landroidx/navigation/NavHostController;Ljava/lang/Object;ILdev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda42;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda47;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->(Landroidx/navigation/NavHostController;Ljava/lang/Object;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda48;->(Landroidx/navigation/NavHostController;)V +Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda49; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda49;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->()V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda5;->(Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->()V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda6;->(Landroidx/navigation/NavHostController;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function1;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7;->()V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda7;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8;->()V +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda8;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9; -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/feature/player/PlayerViewModel;Landroidx/compose/runtime/State;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$$ExternalSyntheticLambda9;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->(Ldev/typetype/android/MainViewModel;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->(Ldev/typetype/android/MainViewModel;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppNavHostKt$AppNavHost$1$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1$1;->(Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$1$1$1;->(Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/navigation/NavHostController;Lkotlin/coroutines/Continuation;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$2$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$2$1$1;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$2$1$1;->(Ljava/lang/Object;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$2$7$5$1$6;->(Ljava/lang/Object;)V Ldev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1; -SPLdev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1;->(Ljava/lang/Object;)V +HSPLdev/typetype/android/AppNavHostKt$AppNavHost$deArrowEnvironment$1$1;->(Ljava/lang/Object;)V +Ldev/typetype/android/AppNavHostKt$WhenMappings; +HSPLdev/typetype/android/AppNavHostKt$WhenMappings;->()V Ldev/typetype/android/AppShellKt; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$1Zq5J--ziI-P3_sH2qn_Y6u3M6g(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$GCNNvbojsw2GG-Mxj3FMwf8V8Nw(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$QtPzzCSx89RPIRJHAhr1vSIJjgY(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$jQKMiga4IUlBWow7YYLA3VjmXgQ(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$mAa56vnHhFW2GGbqnlQVy8qCM2c()Landroidx/compose/runtime/MutableState; -SPLdev/typetype/android/AppShellKt;->$r8$lambda$pn_MOdMzNrF_a7WCKRQi8L3dbCw(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->()V -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$11(Landroidx/compose/runtime/State;)Ldev/typetype/android/feature/player/host/PlayerHostStateSnapshot; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$0(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$1(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$2(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0$0$1$3(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14$0(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$14(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$15(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;IIILandroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$3(Landroidx/compose/runtime/State;)Landroidx/navigation/NavBackStackEntry; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$5$0()Landroidx/compose/runtime/MutableState; -SPLdev/typetype/android/AppShellKt;->AppShell$lambda$9(Landroidx/compose/runtime/MutableState;)Z -SPLdev/typetype/android/AppShellKt;->AppShell(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -SPLdev/typetype/android/AppShellKt;->isAppChromeVisible(Ldev/typetype/android/feature/player/host/PlayerHostTarget;Z)Z +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$9KfNpz2UfznzNA-tHUDzlBc49rA(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$P6UaQzfGI2fexntsRNk562uly70(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZLandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$QuVdSyAbKLIsWTghRqf_tfWf4Hs(Landroidx/compose/runtime/MutableFloatState;F)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$i7ED_rJGMFDg-flVv1UQeGse0bc(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$mAa56vnHhFW2GGbqnlQVy8qCM2c()Landroidx/compose/runtime/MutableState; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$uC41w5yw2wKrjVk_m15a2onBT88(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->$r8$lambda$yuIkXZ5Dpa9rv5anMongZJ7vOrw(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->()V +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$12(Landroidx/compose/runtime/MutableFloatState;)F +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$13(Landroidx/compose/runtime/MutableFloatState;F)V +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$14(Landroidx/compose/runtime/State;)Ldev/typetype/android/feature/player/host/PlayerHostStateSnapshot; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$0(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZLandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$1(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$2(Landroidx/compose/material3/SnackbarHostState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$3(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/foundation/layout/PaddingValues;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0$0$1$5$0(Landroidx/compose/runtime/MutableFloatState;F)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17$0(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/foundation/layout/BoxWithConstraintsScope;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$17(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$18(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;IIILandroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$3(Landroidx/compose/runtime/State;)Landroidx/navigation/NavBackStackEntry; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$5$0()Landroidx/compose/runtime/MutableState; +HSPLdev/typetype/android/AppShellKt;->AppShell$lambda$9(Landroidx/compose/runtime/MutableState;)Z +HSPLdev/typetype/android/AppShellKt;->AppShell(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +HSPLdev/typetype/android/AppShellKt;->isAppChromeVisible(Ldev/typetype/android/feature/player/host/PlayerHostTarget;Z)Z +HSPLdev/typetype/android/AppShellKt;->playerPhoneChromeAlpha(ZLdev/typetype/android/feature/player/host/PlayerHostTarget;ZF)F +HSPLdev/typetype/android/AppShellKt;->shouldReportPlayerProgress(FF)Z +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda10; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda10;->(Landroidx/compose/material3/SnackbarHostState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda10;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda11; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda11;->(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda11;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda12; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda2; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda2;->()V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda2;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda12;->(Landroidx/compose/runtime/MutableState;)V +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda13; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda13;->(Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda13;->invoke(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda14; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda14;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda14;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda3; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;Ldev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;Landroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->()V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda3;->invoke()Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda4; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;III)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->(Landroidx/compose/ui/Modifier;ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;ZLdev/typetype/android/feature/player/host/PlayerHostController;Landroidx/media3/session/MediaController;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZLandroidx/compose/runtime/MutableState;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FLandroidx/compose/material3/SnackbarHostState;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;Landroidx/compose/runtime/MutableFloatState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda4;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->(ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda6; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda6;->(ZZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;Landroidx/compose/runtime/MutableState;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda6;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda7; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda7;->(Landroidx/compose/material3/SnackbarHostState;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda7;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/feature/player/host/PlayerHostController;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;ZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;III)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda8; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->(Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/State;)V -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->(ZZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ZILjava/lang/String;Ljava/lang/String;FZ)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$$ExternalSyntheticLambda9; -SPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->(Landroidx/compose/runtime/MutableState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->(ZZLandroidx/navigation/NavDestination;Landroidx/navigation/NavHostController;Ljava/util/List;FLandroidx/compose/runtime/MutableState;)V +HSPLdev/typetype/android/AppShellKt$$ExternalSyntheticLambda9;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/AppShellKt$AppShell$4$1; -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->(Landroidx/navigation/NavDestination;Landroidx/compose/runtime/MutableState;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/AppShellKt$AppShell$4$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->(Landroidx/navigation/NavDestination;Landroidx/compose/runtime/MutableState;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/AppShellKt$AppShell$4$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +Ldev/typetype/android/DefaultLandingRouteKt; +HSPLdev/typetype/android/DefaultLandingRouteKt;->startupRoute(Ljava/lang/String;Ljava/lang/String;Ldev/typetype/android/domain/auth/SessionStatus;Ljava/lang/String;)Ljava/lang/Object; Ldev/typetype/android/Hilt_MainActivity; -SPLdev/typetype/android/Hilt_MainActivity;->()V -SPLdev/typetype/android/Hilt_MainActivity;->_initHiltInternal()V -SPLdev/typetype/android/Hilt_MainActivity;->componentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; -SPLdev/typetype/android/Hilt_MainActivity;->createComponentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; -SPLdev/typetype/android/Hilt_MainActivity;->generatedComponent()Ljava/lang/Object; -SPLdev/typetype/android/Hilt_MainActivity;->getDefaultViewModelProviderFactory()Landroidx/lifecycle/ViewModelProvider$Factory; -SPLdev/typetype/android/Hilt_MainActivity;->initSavedStateHandleHolders()V -SPLdev/typetype/android/Hilt_MainActivity;->inject()V -SPLdev/typetype/android/Hilt_MainActivity;->onCreate(Landroid/os/Bundle;)V +HSPLdev/typetype/android/Hilt_MainActivity;->()V +HSPLdev/typetype/android/Hilt_MainActivity;->_initHiltInternal()V +HSPLdev/typetype/android/Hilt_MainActivity;->componentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; +HSPLdev/typetype/android/Hilt_MainActivity;->createComponentManager()Ldagger/hilt/android/internal/managers/ActivityComponentManager; +HSPLdev/typetype/android/Hilt_MainActivity;->generatedComponent()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_MainActivity;->getDefaultViewModelProviderFactory()Landroidx/lifecycle/ViewModelProvider$Factory; +HSPLdev/typetype/android/Hilt_MainActivity;->initSavedStateHandleHolders()V +HSPLdev/typetype/android/Hilt_MainActivity;->inject()V +HSPLdev/typetype/android/Hilt_MainActivity;->onCreate(Landroid/os/Bundle;)V Ldev/typetype/android/Hilt_MainActivity$1; -SPLdev/typetype/android/Hilt_MainActivity$1;->(Ldev/typetype/android/Hilt_MainActivity;)V -SPLdev/typetype/android/Hilt_MainActivity$1;->onContextAvailable(Landroid/content/Context;)V +HSPLdev/typetype/android/Hilt_MainActivity$1;->(Ldev/typetype/android/Hilt_MainActivity;)V +HSPLdev/typetype/android/Hilt_MainActivity$1;->onContextAvailable(Landroid/content/Context;)V Ldev/typetype/android/Hilt_TypeTypeApp; -SPLdev/typetype/android/Hilt_TypeTypeApp;->()V -SPLdev/typetype/android/Hilt_TypeTypeApp;->componentManager()Ldagger/hilt/android/internal/managers/ApplicationComponentManager; -SPLdev/typetype/android/Hilt_TypeTypeApp;->generatedComponent()Ljava/lang/Object; -SPLdev/typetype/android/Hilt_TypeTypeApp;->hiltInternalInject()V -SPLdev/typetype/android/Hilt_TypeTypeApp;->onCreate()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->componentManager()Ldagger/hilt/android/internal/managers/ApplicationComponentManager; +HSPLdev/typetype/android/Hilt_TypeTypeApp;->generatedComponent()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_TypeTypeApp;->hiltInternalInject()V +HSPLdev/typetype/android/Hilt_TypeTypeApp;->onCreate()V Ldev/typetype/android/Hilt_TypeTypeApp$1; -SPLdev/typetype/android/Hilt_TypeTypeApp$1;->(Ldev/typetype/android/Hilt_TypeTypeApp;)V -SPLdev/typetype/android/Hilt_TypeTypeApp$1;->get()Ljava/lang/Object; +HSPLdev/typetype/android/Hilt_TypeTypeApp$1;->(Ldev/typetype/android/Hilt_TypeTypeApp;)V +HSPLdev/typetype/android/Hilt_TypeTypeApp$1;->get()Ljava/lang/Object; Ldev/typetype/android/MainActivity; -SPLdev/typetype/android/MainActivity;->$r8$lambda$bUlwZs3rId2DqH0MjONKMRSubBA(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->()V -SPLdev/typetype/android/MainActivity;->()V -SPLdev/typetype/android/MainActivity;->getActiveSessionRepository()Ldev/typetype/android/domain/session/ActiveSessionRepository; -SPLdev/typetype/android/MainActivity;->getViewModel()Ldev/typetype/android/MainViewModel; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$0(Ldev/typetype/android/MainActivity;)Z -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/MainState; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->onCreate$lambda$2(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/MainActivity;->onCreate(Landroid/os/Bundle;)V -SPLdev/typetype/android/MainActivity;->onStart()V -SPLdev/typetype/android/MainActivity;->receiveExternalVideo(Landroid/content/Intent;)V -SPLdev/typetype/android/MainActivity;->receiveOidcCallback(Landroid/content/Intent;)V -SPLdev/typetype/android/MainActivity;->registerPipReceiver()V +HSPLdev/typetype/android/MainActivity;->$r8$lambda$bUlwZs3rId2DqH0MjONKMRSubBA(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->()V +HSPLdev/typetype/android/MainActivity;->()V +HSPLdev/typetype/android/MainActivity;->getActiveSessionRepository()Ldev/typetype/android/domain/session/ActiveSessionRepository; +HSPLdev/typetype/android/MainActivity;->getViewModel()Ldev/typetype/android/MainViewModel; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$0(Ldev/typetype/android/MainActivity;)Z +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/domain/preferences/AppPreferences; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1$0(Landroidx/compose/runtime/State;)Ldev/typetype/android/MainState; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2$1(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->onCreate$lambda$2(Ldev/typetype/android/MainActivity;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/MainActivity;->onCreate(Landroid/os/Bundle;)V +HSPLdev/typetype/android/MainActivity;->onStart()V +HSPLdev/typetype/android/MainActivity;->receiveExternalVideo(Landroid/content/Intent;)V +HSPLdev/typetype/android/MainActivity;->receiveOidcCallback(Landroid/content/Intent;)V +HSPLdev/typetype/android/MainActivity;->registerPipReceiver()V Ldev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/app/Activity;)Z -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/net/ConnectivityManager;Landroid/net/ConnectivityManager$NetworkCallback;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/WindowManager$LayoutParams;I)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/app/Activity;)Z +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/net/ConnectivityManager;Landroid/net/ConnectivityManager$NetworkCallback;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticApiModelOutline0;->m(Landroid/view/WindowManager$LayoutParams;I)V Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda4; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->shouldKeepOnScreen()Z +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda4;->shouldKeepOnScreen()Z Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda5; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda5;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$$ExternalSyntheticLambda8; -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->(Ldev/typetype/android/MainActivity;)V -SPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$$ExternalSyntheticLambda8;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$Companion; -SPLdev/typetype/android/MainActivity$Companion;->()V -SPLdev/typetype/android/MainActivity$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V +HSPLdev/typetype/android/MainActivity$Companion;->()V +HSPLdev/typetype/android/MainActivity$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Ldev/typetype/android/MainActivity$onStart$1; -SPLdev/typetype/android/MainActivity$onStart$1;->(Ldev/typetype/android/MainActivity;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainActivity$onStart$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainActivity$onStart$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$onStart$1;->(Ldev/typetype/android/MainActivity;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainActivity$onStart$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainActivity$onStart$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainActivity$pipReceiver$1; -SPLdev/typetype/android/MainActivity$pipReceiver$1;->(Ldev/typetype/android/MainActivity;)V +HSPLdev/typetype/android/MainActivity$pipReceiver$1;->(Ldev/typetype/android/MainActivity;)V Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$1; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->(Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Landroidx/lifecycle/ViewModelProvider$Factory; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->(Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Landroidx/lifecycle/ViewModelProvider$Factory; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$1;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$2; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->(Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Landroidx/lifecycle/ViewModelStore; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->(Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Landroidx/lifecycle/ViewModelStore; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$2;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainActivity$special$$inlined$viewModels$default$3; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->(Lkotlin/jvm/functions/Function0;Landroidx/activity/ComponentActivity;)V -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Landroidx/lifecycle/viewmodel/CreationExtras; -SPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Ljava/lang/Object; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->(Lkotlin/jvm/functions/Function0;Landroidx/activity/ComponentActivity;)V +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Landroidx/lifecycle/viewmodel/CreationExtras; +HSPLdev/typetype/android/MainActivity$special$$inlined$viewModels$default$3;->invoke()Ljava/lang/Object; Ldev/typetype/android/MainViewModel; -SPLdev/typetype/android/MainViewModel;->()V -SPLdev/typetype/android/MainViewModel;->(Ldev/typetype/android/domain/server/ServerRepository;Ldev/typetype/android/data/network/AccessTokenStore;Ldev/typetype/android/domain/auth/AuthRepository;Ldev/typetype/android/domain/actions/VideoActionsRepository;Ldev/typetype/android/domain/usersettings/UserSettingsRepository;Ldev/typetype/android/domain/profile/ProfileRepository;Ldev/typetype/android/domain/subscriptions/SubscriptionsRepository;Ldev/typetype/android/domain/library/LibraryRepository;Ldev/typetype/android/data/account/ActiveAccountScope;Ldev/typetype/android/domain/playback/PlaybackResumeRepository;Ldev/typetype/android/domain/playback/PlaybackQueueRepository;Ldev/typetype/android/domain/diagnostics/CrashReportRepository;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/domain/preferences/PreferencesRepository;)V -SPLdev/typetype/android/MainViewModel;->access$getCrashReportRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/diagnostics/CrashReportRepository; -SPLdev/typetype/android/MainViewModel;->access$getServerRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/server/ServerRepository; -SPLdev/typetype/android/MainViewModel;->access$get_state$p(Ldev/typetype/android/MainViewModel;)Lkotlinx/coroutines/flow/MutableStateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentProfile()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentServerBaseUrl()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getCurrentServerId()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getEvents()Lkotlinx/coroutines/flow/Flow; -SPLdev/typetype/android/MainViewModel;->getPlayerHostController()Ldev/typetype/android/feature/player/host/PlayerHostController; -SPLdev/typetype/android/MainViewModel;->getPreferences()Lkotlinx/coroutines/flow/StateFlow; -SPLdev/typetype/android/MainViewModel;->getState()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->()V +HSPLdev/typetype/android/MainViewModel;->(Ldev/typetype/android/domain/server/ServerRepository;Ldev/typetype/android/data/network/AccessTokenStore;Ldev/typetype/android/domain/auth/AuthRepository;Ldev/typetype/android/domain/actions/VideoActionsRepository;Ldev/typetype/android/domain/usersettings/UserSettingsRepository;Ldev/typetype/android/domain/profile/ProfileRepository;Ldev/typetype/android/domain/subscriptions/SubscriptionsRepository;Ldev/typetype/android/domain/library/LibraryRepository;Ldev/typetype/android/data/account/ActiveAccountScope;Ldev/typetype/android/data/preferences/StartupLandingStore;Ldev/typetype/android/domain/playback/PlaybackResumeRepository;Ldev/typetype/android/domain/playback/PlaybackQueueRepository;Ldev/typetype/android/domain/diagnostics/CrashReportRepository;Ldev/typetype/android/feature/player/host/PlayerHostController;Ldev/typetype/android/domain/preferences/PreferencesRepository;)V +HSPLdev/typetype/android/MainViewModel;->access$getCrashReportRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/diagnostics/CrashReportRepository; +HSPLdev/typetype/android/MainViewModel;->access$getServerRepository$p(Ldev/typetype/android/MainViewModel;)Ldev/typetype/android/domain/server/ServerRepository; +HSPLdev/typetype/android/MainViewModel;->access$get_state$p(Ldev/typetype/android/MainViewModel;)Lkotlinx/coroutines/flow/MutableStateFlow; +HSPLdev/typetype/android/MainViewModel;->getCurrentProfile()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getCurrentServer()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getEvents()Lkotlinx/coroutines/flow/Flow; +HSPLdev/typetype/android/MainViewModel;->getPlayerHostController()Ldev/typetype/android/feature/player/host/PlayerHostController; +HSPLdev/typetype/android/MainViewModel;->getPreferences()Lkotlinx/coroutines/flow/StateFlow; +HSPLdev/typetype/android/MainViewModel;->getState()Lkotlinx/coroutines/flow/StateFlow; Ldev/typetype/android/MainViewModel$1; -SPLdev/typetype/android/MainViewModel$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainViewModel$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainViewModel$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainViewModel$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainViewModel$1$initial$1; -SPLdev/typetype/android/MainViewModel$1$initial$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V -SPLdev/typetype/android/MainViewModel$1$initial$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -SPLdev/typetype/android/MainViewModel$1$initial$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->(Ldev/typetype/android/MainViewModel;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$1$initial$1;->create(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Lkotlin/coroutines/Continuation; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invoke(Lkotlinx/coroutines/CoroutineScope;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +HSPLdev/typetype/android/MainViewModel$1$initial$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/MainViewModel$Companion; -SPLdev/typetype/android/MainViewModel$Companion;->()V -SPLdev/typetype/android/MainViewModel$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1;->(Lkotlinx/coroutines/flow/Flow;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1;->collect(Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$1;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2;->(Lkotlinx/coroutines/flow/FlowCollector;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$1$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$1$2;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2;->(Lkotlinx/coroutines/flow/Flow;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2;->collect(Lkotlinx/coroutines/flow/FlowCollector;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$2;Lkotlin/coroutines/Continuation;)V -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2;->(Lkotlinx/coroutines/flow/FlowCollector;)V -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2;->emit(Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2$1; -SPLdev/typetype/android/MainViewModel$special$$inlined$map$2$2$1;->(Ldev/typetype/android/MainViewModel$special$$inlined$map$2$2;Lkotlin/coroutines/Continuation;)V +HSPLdev/typetype/android/MainViewModel$Companion;->()V +HSPLdev/typetype/android/MainViewModel$Companion;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V Ldev/typetype/android/SetupNavigationKt; -SPLdev/typetype/android/SetupNavigationKt;->setupDestinations$lambda$0(Landroidx/navigation/NavHostController;Landroidx/compose/animation/AnimatedContentScope;Landroidx/navigation/NavBackStackEntry;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; -SPLdev/typetype/android/SetupNavigationKt;->setupDestinations(Landroidx/navigation/NavGraphBuilder;Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt;->setupDestinations$lambda$0(Landroidx/navigation/NavHostController;Landroidx/compose/animation/AnimatedContentScope;Landroidx/navigation/NavBackStackEntry;Landroidx/compose/runtime/Composer;I)Lkotlin/Unit; +HSPLdev/typetype/android/SetupNavigationKt;->setupDestinations(Landroidx/navigation/NavGraphBuilder;Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda1;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda2;->invoke(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda3;->(Landroidx/navigation/NavHostController;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda4;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5; -SPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V +HSPLdev/typetype/android/SetupNavigationKt$$ExternalSyntheticLambda5;->(Landroidx/navigation/NavHostController;Ldev/typetype/android/MainViewModel;)V Ldev/typetype/android/TypeTypeApp; -SPLdev/typetype/android/TypeTypeApp;->()V -SPLdev/typetype/android/TypeTypeApp;->()V -SPLdev/typetype/android/TypeTypeApp;->getLifecycleDiagnostics()Ldev/typetype/android/data/diagnostics/ApplicationLifecycleDiagnostics; -SPLdev/typetype/android/TypeTypeApp;->getWorkManagerConfiguration()Landroidx/work/Configuration; -SPLdev/typetype/android/TypeTypeApp;->getWorkerFactory()Landroidx/hilt/work/HiltWorkerFactory; -SPLdev/typetype/android/TypeTypeApp;->newImageLoader(Landroid/content/Context;)Lcoil3/ImageLoader; -SPLdev/typetype/android/TypeTypeApp;->onCreate()V \ No newline at end of file +HSPLdev/typetype/android/TypeTypeApp;->()V +HSPLdev/typetype/android/TypeTypeApp;->()V +HSPLdev/typetype/android/TypeTypeApp;->getLifecycleDiagnostics()Ldev/typetype/android/data/diagnostics/ApplicationLifecycleDiagnostics; +HSPLdev/typetype/android/TypeTypeApp;->getWorkManagerConfiguration()Landroidx/work/Configuration; +HSPLdev/typetype/android/TypeTypeApp;->getWorkerFactory()Landroidx/hilt/work/HiltWorkerFactory; +HSPLdev/typetype/android/TypeTypeApp;->newImageLoader(Landroid/content/Context;)Lcoil3/ImageLoader; +HSPLdev/typetype/android/TypeTypeApp;->onCreate()V \ No newline at end of file diff --git a/app/src/test/java/dev/typetype/android/AppShellTransitionTest.kt b/app/src/test/java/dev/typetype/android/AppShellTransitionTest.kt new file mode 100644 index 00000000..37bd78a8 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/AppShellTransitionTest.kt @@ -0,0 +1,27 @@ +package dev.typetype.android + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppShellTransitionTest { + @Test + fun alwaysReportsPlaybackEdges() { + assertTrue(shouldReportPlayerProgress(1f, 0f)) + assertTrue(shouldReportPlayerProgress(0f, 1f)) + assertTrue(shouldReportPlayerProgress(-1f, 2f)) + } + + @Test + fun skipsSmallIntermediateChanges() { + assertFalse(shouldReportPlayerProgress(0.20f, 0.24f)) + assertFalse(shouldReportPlayerProgress(0.80f, 0.76f)) + } + + @Test + fun reportsFullTransitionSteps() { + assertTrue(shouldReportPlayerProgress(0f, 0.05f)) + assertTrue(shouldReportPlayerProgress(0.95f, 1f)) + assertTrue(shouldReportPlayerProgress(2f, 1f)) + } +} diff --git a/app/src/test/java/dev/typetype/android/StartupRouteTest.kt b/app/src/test/java/dev/typetype/android/StartupRouteTest.kt new file mode 100644 index 00000000..001b7f17 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/StartupRouteTest.kt @@ -0,0 +1,77 @@ +package dev.typetype.android + +import dev.typetype.android.core.ui.navigation.HomeRoute +import dev.typetype.android.core.ui.navigation.LibraryLandingRoute +import dev.typetype.android.core.ui.navigation.LoginRoute +import dev.typetype.android.core.ui.navigation.SubscriptionsRoute +import dev.typetype.android.core.ui.navigation.WelcomeRoute +import dev.typetype.android.domain.auth.SessionStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +class StartupRouteTest { + @Test + fun `missing server starts welcome`() { + assertEquals( + WelcomeRoute, + startupRoute( + serverId = null, + accountId = null, + sessionStatus = SessionStatus.Invalid, + defaultLandingPage = "", + ), + ) + } + + @Test + fun `invalid session starts login and preserves the selected account`() { + assertEquals( + LoginRoute("server", "account"), + startupRoute( + serverId = "server", + accountId = "account", + sessionStatus = SessionStatus.Invalid, + defaultLandingPage = "", + ), + ) + } + + @Test + fun `known session starts the cached subscription landing page`() { + assertEquals( + SubscriptionsRoute, + startupRoute( + serverId = "server", + accountId = "account", + sessionStatus = SessionStatus.Unknown, + defaultLandingPage = "subscriptions", + ), + ) + } + + @Test + fun `known session starts the cached library landing page`() { + assertEquals( + LibraryLandingRoute("watch-later"), + startupRoute( + serverId = "server", + accountId = "account", + sessionStatus = SessionStatus.Valid, + defaultLandingPage = "watch-later", + ), + ) + } + + @Test + fun `unknown landing page starts home`() { + assertEquals( + HomeRoute, + startupRoute( + serverId = "server", + accountId = "account", + sessionStatus = SessionStatus.Valid, + defaultLandingPage = "", + ), + ) + } +} diff --git a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt index c6a360cc..773bfa67 100644 --- a/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt +++ b/app/src/test/java/dev/typetype/android/core/ui/share/ShareUrlsTest.kt @@ -31,4 +31,38 @@ class ShareUrlsTest { buildShareUrl(null, "https://youtube.com/watch?v=dQw4w9WgXcQ"), ) } + + @Test + fun proxiesRemoteImagesThroughTheActiveServer() { + assertEquals( + "https://watch.example/api/proxy?url=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fabc%2Fhqdefault.jpg", + buildImageUrl( + "https://watch.example/api", + "https://i.ytimg.com/vi/abc/hqdefault.jpg", + ), + ) + } + + @Test + fun resolvesServerRelativeImagesAndKeepsServerImagesUnchanged() { + assertEquals( + "https://watch.example/api/images/avatar.jpg", + buildImageUrl( + "https://watch.example/api", + "https://watch.example/api/images/avatar.jpg", + ), + ) + assertEquals( + "https://watch.example/images/avatar.jpg", + buildImageUrl("https://watch.example/api", "/images/avatar.jpg"), + ) + } + + @Test + fun leavesNonMediaRemoteImagesDirect() { + assertEquals( + "https://cdn.example/avatar.jpg", + buildImageUrl("https://watch.example/api", "https://cdn.example/avatar.jpg"), + ) + } } diff --git a/app/src/test/java/dev/typetype/android/data/imports/PipePipeImportValidationTest.kt b/app/src/test/java/dev/typetype/android/data/imports/PipePipeImportValidationTest.kt index 475d6b6c..6fa79c13 100644 --- a/app/src/test/java/dev/typetype/android/data/imports/PipePipeImportValidationTest.kt +++ b/app/src/test/java/dev/typetype/android/data/imports/PipePipeImportValidationTest.kt @@ -56,6 +56,7 @@ class PipePipeImportValidationTest { assertEquals( listOf( "subscriptions", + "subscriptionGroups", "history", "playlists", "watchLater", diff --git a/app/src/test/java/dev/typetype/android/data/network/TypeTypePortabilityContractTest.kt b/app/src/test/java/dev/typetype/android/data/network/TypeTypePortabilityContractTest.kt new file mode 100644 index 00000000..9be1a3ff --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/network/TypeTypePortabilityContractTest.kt @@ -0,0 +1,90 @@ +package dev.typetype.android.data.network + +import dev.typetype.android.data.network.dto.PortabilityApplyRequestDto +import dev.typetype.android.data.network.dto.PortabilityExportRequestDto +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +class TypeTypePortabilityContractTest { + private lateinit var server: MockWebServer + private lateinit var api: TypeTypeImportApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient()) + .addConverterFactory( + Json { ignoreUnknownKeys = true; explicitNulls = false } + .asConverterFactory("application/json".toMediaType()), + ) + .build() + .create(TypeTypeImportApi::class.java) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun portabilityFormatsDecodeJobCapabilities() = runBlockingCompat { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + [{ + "format":"typetype", + "adapterVersion":2, + "defaultExtension":"json", + "contentType":"application/json", + "capabilities":[ + {"category":"subscriptions","directions":["import","export"],"fidelity":"complete"}, + {"category":"history","directions":["export"],"fidelity":"partial"} + ] + }] + """.trimIndent(), + ), + ) + + val formats = api.portabilityFormats().body().orEmpty() + assertEquals("typetype", formats.single().format) + assertEquals("/portability/formats", server.takeRequest().path) + } + + @Test + fun portabilityExportAndApplyUseExpectedContract() = runBlockingCompat { + val body = """ + {"id":"job-1","kind":"export","state":"queued","createdAt":1,"updatedAt":2, + "progress":{"phase":"collecting","unit":"records","processed":2,"total":10}} + """.trimIndent() + repeat(2) { server.enqueue(MockResponse().setResponseCode(200).setBody(body)) } + + val export = api.startPortabilityExport( + request = PortabilityExportRequestDto("typetype", listOf("subscriptions")), + ).body() ?: error("Missing export job") + val applied = api.applyPortabilityImport( + jobId = export.id, + request = PortabilityApplyRequestDto(listOf("history"), "skip"), + ).body() ?: error("Missing applied job") + + assertEquals("/portability/exports", server.takeRequest().path) + assertEquals("/portability/jobs/job-1/apply", server.takeRequest().path) + assertEquals("queued", export.state) + assertEquals("queued", applied.state) + } +} + +private fun runBlockingCompat(block: suspend () -> T): T = + kotlinx.coroutines.runBlocking { block() } diff --git a/app/src/test/java/dev/typetype/android/data/network/TypeTypeYoutubeSessionContractTest.kt b/app/src/test/java/dev/typetype/android/data/network/TypeTypeYoutubeSessionContractTest.kt index e2b5af8e..d2041ccf 100644 --- a/app/src/test/java/dev/typetype/android/data/network/TypeTypeYoutubeSessionContractTest.kt +++ b/app/src/test/java/dev/typetype/android/data/network/TypeTypeYoutubeSessionContractTest.kt @@ -21,7 +21,11 @@ class TypeTypeYoutubeSessionContractTest { server.start() api = RetrofitFactory( sessionClient = OkHttpClient(), - json = Json { ignoreUnknownKeys = true }, + json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + }, ).create(server.url("/").toString()) } @@ -74,6 +78,23 @@ class TypeTypeYoutubeSessionContractTest { assertEquals("/youtube-session", disconnectRequest.path) } + @Test + fun browserStartWithoutReturnTargetSendsAnEmptyObject() = runBlocking { + server.enqueue( + jsonResponse( + """{"sessionId":"remote-id","wsUrl":"/youtube-session/browser/remote-id?token=one-time","expiresAt":123}""", + code = 201, + ), + ) + + api.startYoutubeRemoteBrowser(YoutubeRemoteBrowserStartRequest()) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/youtube-session/browser/start", request.path) + assertEquals("{}", request.body.readUtf8()) + } + private fun jsonResponse(body: String, code: Int = 200) = MockResponse() .setResponseCode(code) .setHeader("Content-Type", "application/json") diff --git a/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt new file mode 100644 index 00000000..31528267 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/preferences/DataStorePreferencesRepositoryTest.kt @@ -0,0 +1,90 @@ +package dev.typetype.android.data.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import dev.typetype.android.domain.preferences.AppearanceFont +import dev.typetype.android.domain.preferences.AppearanceMode +import dev.typetype.android.domain.preferences.AppearanceMotion +import dev.typetype.android.domain.preferences.AppearancePersonality +import dev.typetype.android.domain.preferences.AppearanceTheme +import dev.typetype.android.domain.preferences.MangaHeadlineMarker +import dev.typetype.android.domain.preferences.MangaPaper +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataStorePreferencesRepositoryTest { + @Test + fun `appearance choices survive repository recreation`() = runBlocking { + val directory = Files.createTempDirectory("appearance-preferences").toFile() + val file = File(directory, "appearance.preferences_pb") + val firstJob = Job() + val first = DataStorePreferencesRepository( + PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.IO + firstJob)) { file }, + ) + first.setAppearancePersonality(AppearancePersonality.Manga) + first.setAppearanceMode(AppearanceMode.Dark) + first.setAppearanceAmoled(true) + first.setAppearanceTheme(AppearanceTheme.Forest) + first.setAppearanceFont(AppearanceFont.Expressive) + first.setAppearanceMotion(AppearanceMotion.Off) + first.setMangaPaper(MangaPaper.Nord) + first.setMangaHeadlineMarker(MangaHeadlineMarker.SpeedLines) + first.setMangaPanelTilt(true) + firstJob.cancelAndJoin() + + val secondJob = Job() + val recreated = DataStorePreferencesRepository( + PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.IO + secondJob)) { file }, + ) + val preferences = recreated.observe().first() + + assertEquals(AppearancePersonality.Manga, preferences.appearancePersonality) + assertEquals(AppearanceMode.Dark, preferences.appearanceMode) + assertEquals(true, preferences.appearanceAmoled) + assertEquals(AppearanceTheme.Forest, preferences.appearanceTheme) + assertEquals(AppearanceFont.Expressive, preferences.appearanceFont) + assertEquals(AppearanceMotion.Off, preferences.appearanceMotion) + assertEquals(MangaPaper.Nord, preferences.mangaPaper) + assertEquals(MangaHeadlineMarker.SpeedLines, preferences.mangaHeadlineMarker) + assertEquals(true, preferences.mangaPanelTilt) + secondJob.cancelAndJoin() + directory.deleteRecursively() + Unit + } + + @Test + fun `seek interval and codec survive repository recreation`() = runBlocking { + val directory = Files.createTempDirectory("player-preferences").toFile() + val file = File(directory, "player.preferences_pb") + val firstJob = Job() + val firstScope = CoroutineScope(Dispatchers.IO + firstJob) + val first = DataStorePreferencesRepository( + PreferenceDataStoreFactory.create(scope = firstScope) { file }, + ) + first.setPlayerDoubleTapSeekSeconds(5) + first.setPlayerPreferredCodec("vp9") + first.setPlayerAccessibleControlsEnabled(true) + firstJob.cancelAndJoin() + + val secondJob = Job() + val secondScope = CoroutineScope(Dispatchers.IO + secondJob) + val recreated = DataStorePreferencesRepository( + PreferenceDataStoreFactory.create(scope = secondScope) { file }, + ) + val preferences = recreated.observe().first() + + assertEquals(5, preferences.playerDoubleTapSeekSeconds) + assertEquals("vp9", preferences.playerPreferredCodec) + assertEquals(true, preferences.playerAccessibleControlsEnabled) + secondJob.cancelAndJoin() + directory.deleteRecursively() + Unit + } +} diff --git a/app/src/test/java/dev/typetype/android/data/preferences/StartupLandingStoreTest.kt b/app/src/test/java/dev/typetype/android/data/preferences/StartupLandingStoreTest.kt new file mode 100644 index 00000000..8e35b89d --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/preferences/StartupLandingStoreTest.kt @@ -0,0 +1,71 @@ +package dev.typetype.android.data.preferences + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import dev.typetype.android.data.account.AccountScope +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class StartupLandingStoreTest { + @Test + fun `landing page is scoped to server and account`() = runBlocking { + val (store, job, directory) = store() + + store.setLandingPage( + AccountScope(serverId = "server", accountId = "account"), + landingPage = "subscriptions", + ) + store.setLandingPage( + AccountScope(serverId = "server", accountId = "other"), + landingPage = "history", + ) + + assertEquals( + "subscriptions", + store.observeLandingPage( + AccountScope(serverId = "server", accountId = "account"), + ).first(), + ) + assertEquals( + "history", + store.observeLandingPage( + AccountScope(serverId = "server", accountId = "other"), + ).first(), + ) + job.cancelAndJoin() + directory.deleteRecursively() + Unit + } + + @Test + fun `blank landing page removes the stored value`() = runBlocking { + val (store, job, directory) = store() + val account = AccountScope(serverId = "server", accountId = "account") + + store.setLandingPage(account, "playlists") + store.setLandingPage(account, " ") + + assertEquals("", store.observeLandingPage(account).first()) + job.cancelAndJoin() + directory.deleteRecursively() + Unit + } + + private fun store(): Triple { + val directory = Files.createTempDirectory("startup-landing").toFile() + val file = File(directory, "startup.preferences_pb") + val job = Job() + val coroutineScope = CoroutineScope(Dispatchers.IO + job) + val store = StartupLandingStore( + PreferenceDataStoreFactory.create(scope = coroutineScope) { file }, + ) + return Triple(store, job, directory) + } +} diff --git a/app/src/test/java/dev/typetype/android/domain/usersettings/QualityNamesTest.kt b/app/src/test/java/dev/typetype/android/domain/usersettings/QualityNamesTest.kt new file mode 100644 index 00000000..ca585ebd --- /dev/null +++ b/app/src/test/java/dev/typetype/android/domain/usersettings/QualityNamesTest.kt @@ -0,0 +1,22 @@ +package dev.typetype.android.domain.usersettings + +import org.junit.Assert.assertEquals +import org.junit.Test + +class QualityNamesTest { + @Test + fun normalizesRawServerLabelsToHeightOnly() { + assertEquals("1080p", normalizeQualityName("1080p60 Premium")) + assertEquals("720p", normalizeQualityName(" Medium · 720p30 ")) + } + + @Test + fun preservesExplicitHighDynamicRange() { + assertEquals("2160p HDR", normalizeQualityName("2160p HDR")) + } + + @Test + fun providesSensibleFallback() { + assertEquals("1080p", normalizeQualityName("")) + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt index 3cc5da3c..0a1981ca 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt @@ -91,9 +91,9 @@ class PlaybackSourceSelectorTest { } @Test - fun `recommended codec prefers baseline hardware before resolution`() { - val baseline = video("baseline", height = 720, codec = "avc1.64001f") - val modern = video("modern", height = 1080, codec = "vp09.00.40.08") + fun `smart codec keeps target resolution before codec efficiency`() { + val baseline = video("baseline", height = 1080, codec = "avc1.640028") + val modern = video("modern", height = 720, codec = "av01.0.08M.08") val support = FakeCodecSupport( video = mapOf( baseline.url to DecoderSupport.Hardware, @@ -107,6 +107,34 @@ class PlaybackSourceSelectorTest { ) } + @Test + fun `smart codec prefers hardware AV1 then VP9 then H264`() { + val h264 = video("h264", height = 1080, codec = "avc1.640028") + val vp9 = video("vp9", height = 1080, codec = "vp09.00.41.08") + val av1 = video("av1", height = 1080, codec = "av01.0.08M.08") + val support = FakeCodecSupport( + video = listOf(h264, vp9, av1).associate { it.url to DecoderSupport.Hardware }, + ) + + assertEquals(av1, listOf(h264, vp9, av1).pickVideo("1080p", support)) + assertEquals(vp9, listOf(h264, vp9).pickVideo("1080p", support)) + assertEquals(h264, listOf(h264).pickVideo("1080p", support)) + } + + @Test + fun `smart codec never selects software AV1 over hardware baseline`() { + val h264 = video("h264", height = 720, codec = "avc1.64001f") + val av1 = video("av1", height = 1080, codec = "av01.0.08M.08") + val support = FakeCodecSupport( + video = mapOf( + h264.url to DecoderSupport.Hardware, + av1.url to DecoderSupport.Software, + ), + ) + + assertEquals(h264, listOf(av1, h264).pickVideo("1080p", support)) + } + @Test fun `explicit codec keeps the requested quality`() { val hardware = video("hardware", height = 720, codec = "vp09.00.31.08") diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerCommentsPagingConfigTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerCommentsPagingConfigTest.kt new file mode 100644 index 00000000..6d1e9a94 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerCommentsPagingConfigTest.kt @@ -0,0 +1,18 @@ +package dev.typetype.android.feature.player + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class PlayerCommentsPagingConfigTest { + @Test + fun `comment paging keeps a bounded in-memory cache`() { + val config = COMMENTS_PAGING_CONFIG + + assertEquals(30, config.pageSize) + assertEquals(60, config.initialLoadSize) + assertEquals(10, config.prefetchDistance) + assertEquals(240, config.maxSize) + assertFalse(config.enablePlaceholders) + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerDoubleTapActionTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerDoubleTapActionTest.kt new file mode 100644 index 00000000..9bb867f3 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerDoubleTapActionTest.kt @@ -0,0 +1,28 @@ +package dev.typetype.android.feature.player + +import dev.typetype.android.feature.player.components.PlayerDoubleTapAction +import dev.typetype.android.feature.player.components.doubleTapAction +import dev.typetype.android.feature.player.components.isEnabled +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerDoubleTapActionTest { + @Test + fun `double tap divides the player into three stable zones`() { + assertEquals(PlayerDoubleTapAction.Rewind, doubleTapAction(0f, 900f)) + assertEquals(PlayerDoubleTapAction.Rewind, doubleTapAction(299f, 900f)) + assertEquals(PlayerDoubleTapAction.TogglePlayback, doubleTapAction(300f, 900f)) + assertEquals(PlayerDoubleTapAction.TogglePlayback, doubleTapAction(600f, 900f)) + assertEquals(PlayerDoubleTapAction.Forward, doubleTapAction(601f, 900f)) + assertEquals(PlayerDoubleTapAction.Forward, doubleTapAction(900f, 900f)) + } + + @Test + fun `center playback toggle remains available when seek gestures are disabled`() { + assertFalse(PlayerDoubleTapAction.Rewind.isEnabled(seekEnabled = false)) + assertTrue(PlayerDoubleTapAction.TogglePlayback.isEnabled(seekEnabled = false)) + assertFalse(PlayerDoubleTapAction.Forward.isEnabled(seekEnabled = false)) + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionStateTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionStateTest.kt index 1d4b0829..9acd0a17 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionStateTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerPlaybackSelectionStateTest.kt @@ -89,6 +89,20 @@ class PlayerPlaybackSelectionStateTest { assertEquals(AV1_CODEC_KEY, state.selectedCodec) } + @Test + fun `persisted codec initializes a recreated selection state`() { + val recreated = PlayerPlaybackSelectionState( + stream = stream(), + defaultAudioLanguage = "", + subtitlesEnabled = false, + defaultSubtitleLanguage = "", + preferOriginalLanguage = false, + preferredCodec = VP9_CODEC_KEY, + ) + + assertEquals(VP9_CODEC_KEY, recreated.selectedCodec) + } + @Test fun `server playback speed is applied and bounded`() { val configured = PlayerPlaybackSelectionState( diff --git a/app/src/test/java/dev/typetype/android/feature/player/RuntimeCodecFallbackTest.kt b/app/src/test/java/dev/typetype/android/feature/player/RuntimeCodecFallbackTest.kt new file mode 100644 index 00000000..c686bfab --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/RuntimeCodecFallbackTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android.feature.player + +import androidx.media3.common.PlaybackException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RuntimeCodecFallbackTest { + @Test + fun `declared unsupported formats trigger smart fallback`() { + assertTrue( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + emptyList(), + ), + ) + assertTrue( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, + emptyList(), + ), + ) + } + + @Test + fun `decoder initialization falls back unless the surface failed`() { + assertTrue( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + listOf("Failed to initialize c2.android.av1.decoder"), + ), + ) + assertFalse( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + listOf("setOutputSurface failed"), + ), + ) + } + + @Test + fun `network and stream failures never poison codec support`() { + assertFalse( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + listOf("connection reset"), + ), + ) + assertFalse( + isCodecCapabilityFailure( + PlaybackException.ERROR_CODE_DECODING_FAILED, + listOf("malformed sample"), + ), + ) + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/host/FullscreenOrientationStateTest.kt b/app/src/test/java/dev/typetype/android/feature/player/host/FullscreenOrientationStateTest.kt index ad33b0e8..9873b298 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/host/FullscreenOrientationStateTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/host/FullscreenOrientationStateTest.kt @@ -46,6 +46,7 @@ class FullscreenOrientationStateTest { ) assertTrue(rotated.state.locksLandscape) + assertTrue(rotated.state.restoresPortraitOnExit) assertNull(rotated.fullscreenRequest) } @@ -55,6 +56,9 @@ class FullscreenOrientationStateTest { fullscreen = false, orientation = DeviceOrientation.Landscape, ) + + assertTrue(exited.state.restoresPortraitOnExit) + val environment = exited.state.onEnvironmentChanged( orientation = DeviceOrientation.Landscape, hasFullscreenMedia = true, diff --git a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt index 54109358..17440d5b 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostControllerTest.kt @@ -91,7 +91,7 @@ class PlayerHostControllerTest { } @Test - fun `leaving Shorts only closes embedded playback`() { + fun `leaving Shorts without previous playback hides embedded playback`() { val controller = PlayerHostController(FakePlaybackQueueController()) controller.openEmbeddedVideo("short", autoplay = true) controller.closeEmbeddedPlayback() @@ -102,6 +102,43 @@ class PlayerHostControllerTest { assertEquals(PlayerHostTarget.Expanded, controller.state.value.target) } + @Test + fun `leaving Shorts restores the mini player that opened it`() { + val controller = PlayerHostController(FakePlaybackQueueController()) + controller.openVideo("video") + controller.minimize() + + controller.openEmbeddedVideo( + url = "short", + autoplay = true, + returnPositionMillis = 18_000L, + returnPlayWhenReady = true, + ) + controller.openEmbeddedVideo( + url = "next-short", + autoplay = true, + returnPositionMillis = 2_000L, + returnPlayWhenReady = false, + ) + controller.closeEmbeddedPlayback() + + assertEquals(PlayerHostTarget.Mini, controller.state.value.target) + assertEquals("video", controller.state.value.videoUrl) + assertEquals(18_000L, controller.state.value.resumePositionMillis) + assertTrue(controller.state.value.initialPlayWhenReady) + } + + @Test + fun `leaving Shorts restores an expanded player`() { + val controller = PlayerHostController(FakePlaybackQueueController()) + controller.openVideo("video") + + controller.openEmbeddedVideo("short", autoplay = true) + controller.closeEmbeddedPlayback() + + assertEquals(PlayerHostTarget.Expanded, controller.state.value.target) + } + @Test fun `expanded Short collapses back into embedded playback`() { val controller = PlayerHostController(FakePlaybackQueueController()) @@ -125,6 +162,32 @@ class PlayerHostControllerTest { assertEquals(PlayerHostTarget.Mini, controller.state.value.target) } + + @Test + fun `autoplay keeps the mini player minimized`() { + val controller = PlayerHostController(FakePlaybackQueueController()) + controller.openVideo("first") + controller.minimize() + + controller.continueWithVideo("second") + + val state = controller.state.value + assertEquals("second", state.videoUrl) + assertEquals(PlayerHostTarget.Mini, state.target) + assertNull(state.resumePositionMillis) + assertTrue(state.initialPlayWhenReady) + } + + @Test + fun `autoplay keeps the expanded player expanded`() { + val controller = PlayerHostController(FakePlaybackQueueController()) + controller.openVideo("first") + + controller.continueWithVideo("second") + + assertEquals("second", controller.state.value.videoUrl) + assertEquals(PlayerHostTarget.Expanded, controller.state.value.target) + } } private class FakePlaybackQueueController : PlaybackQueueController { diff --git a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostTransitionTest.kt b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostTransitionTest.kt index 171f6a92..930560f9 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostTransitionTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/host/PlayerHostTransitionTest.kt @@ -30,6 +30,7 @@ class PlayerHostTransitionTest { assertFalse(moving.isSettledMini) assertTrue(settled.isSettledMini) assertEquals(64, settled.heightPx) + assertEquals(1f, settled.miniContentAlpha) } @Test diff --git a/app/src/test/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPathTest.kt b/app/src/test/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPathTest.kt new file mode 100644 index 00000000..558a41f9 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/settings/blocked/BlockedItemDisplayPathTest.kt @@ -0,0 +1,27 @@ +package dev.typetype.android.feature.settings.blocked + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BlockedItemDisplayPathTest { + @Test + fun youtubeWatchUrlShowsOnlyTheVideoIdentity() { + assertEquals( + "/xcbadhbca", + blockedItemDisplayPath("https://www.youtube.com/watch?v=xcbadhbca"), + ) + } + + @Test + fun channelUrlKeepsTheUsefulPathWithoutTheDomain() { + assertEquals( + "/channel/UC123", + blockedItemDisplayPath("https://www.youtube.com/channel/UC123"), + ) + } + + @Test + fun relativePathsStayUnchanged() { + assertEquals("/@creator", blockedItemDisplayPath("/@creator")) + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/settings/content/ContentSettingsActionTest.kt b/app/src/test/java/dev/typetype/android/feature/settings/content/ContentSettingsActionTest.kt index 693c6f15..b25ce606 100644 --- a/app/src/test/java/dev/typetype/android/feature/settings/content/ContentSettingsActionTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/settings/content/ContentSettingsActionTest.kt @@ -27,28 +27,6 @@ class ContentSettingsActionTest { assertEquals(original.defaultQuality, updated.defaultQuality) } - @Test - fun `master visibility action updates every related server preference atomically`() { - val hidden = UserSettings(supportsHideSubscriptionLiveStreams = true) - .updatedBy(ContentSettingsAction.SetAllHidden(true)) - - assertTrue(hidden.hideHomeRecommendations) - assertTrue(hidden.hideContinueWatching) - assertTrue(hidden.hideRelatedVideos) - assertTrue(hidden.hideComments) - assertTrue(hidden.hideShorts) - assertTrue(hidden.hideSubscriptionLiveStreams) - assertEquals("home", hidden.defaultLandingPage) - } - - @Test - fun `master visibility action does not invent old server capability`() { - val hidden = UserSettings().updatedBy(ContentSettingsAction.SetAllHidden(true)) - - assertFalse(hidden.hideSubscriptionLiveStreams) - assertFalse(hidden.supportsHideSubscriptionLiveStreams) - } - @Test fun `landing page action preserves unrelated preferences`() { val updated = UserSettings(autoplay = false).updatedBy( diff --git a/app/src/test/java/dev/typetype/android/services/PlaybackBufferPolicyTest.kt b/app/src/test/java/dev/typetype/android/services/PlaybackBufferPolicyTest.kt new file mode 100644 index 00000000..c4687fcc --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/PlaybackBufferPolicyTest.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.services + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackBufferPolicyTest { + @Test + fun `low-memory devices receive the smallest bounded budget`() { + assertEquals( + 32 * MEBIBYTE, + playbackBufferPolicy(memoryClassMb = 256, isLowRamDevice = true) + .targetBufferBytes, + ) + assertEquals( + 32 * MEBIBYTE, + playbackBufferPolicy(memoryClassMb = 96, isLowRamDevice = false) + .targetBufferBytes, + ) + } + + @Test + fun `medium heaps receive a moderate bounded budget`() { + assertEquals( + 64 * MEBIBYTE, + playbackBufferPolicy(memoryClassMb = 128, isLowRamDevice = false) + .targetBufferBytes, + ) + } + + @Test + fun `large heaps remain capped below the Media3 video default`() { + val policy = playbackBufferPolicy( + memoryClassMb = 512, + isLowRamDevice = false, + ) + + assertEquals(96 * MEBIBYTE, policy.targetBufferBytes) + assertEquals(15_000, policy.minBufferMs) + assertEquals(30_000, policy.maxBufferMs) + assertEquals(15_000, policy.backBufferMs) + } + + private companion object { + const val MEBIBYTE = 1024 * 1024 + } +} diff --git a/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowCacheTest.kt b/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowCacheTest.kt index 3b5354cb..0aa55e37 100644 --- a/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowCacheTest.kt +++ b/app/src/test/java/dev/typetype/android/services/SabrPlaybackWindowCacheTest.kt @@ -71,4 +71,36 @@ class SabrPlaybackWindowCacheTest { assertNull(cache.take(session.binding)) } + + @Test + fun `abandoned sessions cannot grow the cache without a bound`() { + val cache = SabrPlaybackWindowCache() + val sessions = (1..5).map { index -> playableSession("session-$index") } + + sessions.forEach(cache::put) + + assertNull(cache.take(sessions.first().binding)) + sessions.drop(1).forEach { assertEquals(it, cache.take(it.binding)) } + } + + private fun playableSession(sessionId: String) = SabrPlaybackSession( + sessionId = sessionId, + manifestUrl = "https://instance.example/api/sabr/playback/$sessionId/manifest", + generation = 1, + videoItag = 137, + audioItag = 140, + audioTrackId = null, + audioWindow = SabrPlaybackWindowTrack( + itag = 140, + mimeType = "audio/mp4", + initializationUrl = "https://instance.example/$sessionId/init", + segments = listOf( + SabrPlaybackWindowSegment( + url = "https://instance.example/$sessionId/segment", + startMs = 0, + durationMs = 10_000, + ), + ), + ), + ) } diff --git a/assets/screenshots/android-add-instance.png b/assets/screenshots/android-add-instance.png new file mode 100644 index 00000000..2d5bbcb7 Binary files /dev/null and b/assets/screenshots/android-add-instance.png differ diff --git a/assets/screenshots/android-comments.png b/assets/screenshots/android-comments.png index 026f0b0e..3794f84f 100644 Binary files a/assets/screenshots/android-comments.png and b/assets/screenshots/android-comments.png differ diff --git a/assets/screenshots/android-home.png b/assets/screenshots/android-home.png index 27a7e913..02a33be2 100644 Binary files a/assets/screenshots/android-home.png and b/assets/screenshots/android-home.png differ diff --git a/assets/screenshots/android-library.png b/assets/screenshots/android-library.png index 115d60e4..f0fc7c66 100644 Binary files a/assets/screenshots/android-library.png and b/assets/screenshots/android-library.png differ diff --git a/assets/screenshots/android-player.png b/assets/screenshots/android-player.png index 00c96996..ee097d5c 100644 Binary files a/assets/screenshots/android-player.png and b/assets/screenshots/android-player.png differ diff --git a/assets/screenshots/android-search.png b/assets/screenshots/android-search.png index 73d08491..2e21b0f2 100644 Binary files a/assets/screenshots/android-search.png and b/assets/screenshots/android-search.png differ diff --git a/assets/screenshots/android-settings.png b/assets/screenshots/android-settings.png index 401716d5..873a2d14 100644 Binary files a/assets/screenshots/android-settings.png and b/assets/screenshots/android-settings.png differ diff --git a/assets/screenshots/android-welcome.png b/assets/screenshots/android-welcome.png new file mode 100644 index 00000000..af4b7bb2 Binary files /dev/null and b/assets/screenshots/android-welcome.png differ diff --git a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt index 3cd87e1b..bf26d8c3 100644 --- a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt +++ b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/BaselineProfileGenerator.kt @@ -26,6 +26,7 @@ class BaselineProfileGenerator { val STARTUP_RULE = Regex( "^[HSP]*Ldev/typetype/android/" + "(MainActivity|MainViewModel|TypeTypeApp|AppShellKt|AppNavHostKt|" + + "DefaultLandingRouteKt|StartupLandingStore|AccessTokenStore|" + "SetupNavigationKt|" + "Hilt_MainActivity|Hilt_TypeTypeApp)(\\$|;)", ) diff --git a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/MainStartupMacrobenchmark.kt b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/MainStartupMacrobenchmark.kt new file mode 100644 index 00000000..e8dd6866 --- /dev/null +++ b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/MainStartupMacrobenchmark.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.baselineprofile + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.MemoryUsageMetric +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainStartupMacrobenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + @OptIn(ExperimentalMetricApi::class) + fun startup() = benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf( + StartupTimingMetric(), + MemoryUsageMetric( + MemoryUsageMetric.Mode.Max, + listOf( + MemoryUsageMetric.SubMetric.RssAnon, + MemoryUsageMetric.SubMetric.RssFile, + MemoryUsageMetric.SubMetric.RssShmem, + ), + ), + ), + compilationMode = CompilationMode.Partial(), + startupMode = StartupMode.COLD, + iterations = 5, + setupBlock = { pressHome() }, + ) { + pressHome() + startActivityAndWait() + } + + private companion object { + const val PACKAGE_NAME = "dev.typetype.android" + } +} diff --git a/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/PlayerHostMacrobenchmark.kt b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/PlayerHostMacrobenchmark.kt new file mode 100644 index 00000000..951de1bf --- /dev/null +++ b/baseline-profile/src/main/java/dev/typetype/android/baselineprofile/PlayerHostMacrobenchmark.kt @@ -0,0 +1,77 @@ +package dev.typetype.android.baselineprofile + +import android.content.Intent +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.MemoryUsageMetric +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlayerHostMacrobenchmark { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @OptIn(ExperimentalMetricApi::class) + @Test + fun playerMorph() = benchmarkRule.measureRepeated( + packageName = PACKAGE_NAME, + metrics = listOf( + FrameTimingMetric(), + MemoryUsageMetric( + mode = MemoryUsageMetric.Mode.Max, + subMetrics = listOf( + MemoryUsageMetric.SubMetric.HeapSize, + MemoryUsageMetric.SubMetric.RssAnon, + ), + ), + ), + compilationMode = CompilationMode.Partial(), + iterations = 5, + setupBlock = { + killProcess() + startActivityAndWait(benchmarkIntent()) + }, + ) { + repeat(3) { + swipeDown(durationMs = 700, steps = 45) + swipeUp(durationMs = 700, steps = 45) + swipeDown(durationMs = 120, steps = 8) + swipeUp(durationMs = 120, steps = 8) + } + } + + private fun MacrobenchmarkScope.swipeDown(durationMs: Int, steps: Int) { + device.swipe( + device.displayWidth / 2, + device.displayHeight / 4, + device.displayWidth / 2, + device.displayHeight * 4 / 5, + steps, + ) + device.waitForIdle((durationMs + 800).toLong()) + } + + private fun MacrobenchmarkScope.swipeUp(durationMs: Int, steps: Int) { + device.swipe( + device.displayWidth / 2, + device.displayHeight * 9 / 10, + device.displayWidth / 2, + device.displayHeight / 4, + steps, + ) + device.waitForIdle((durationMs + 800).toLong()) + } + + private fun benchmarkIntent() = Intent().setClassName(PACKAGE_NAME, ACTIVITY_NAME) + + private companion object { + const val PACKAGE_NAME = "dev.typetype.android" + const val ACTIVITY_NAME = "dev.typetype.android.benchmark.PlayerHostBenchmarkActivity" + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 85e61eac..444ed2e5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.androidx.navigation.safeargs) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.aboutlibraries) apply false } tasks.register("clean") { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98278193..66f59e62 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ datastore = "1.2.1" securityCrypto = "1.1.0" tink = "1.23.0" okhttp = "5.4.0" +aboutlibraries = "15.1.1" [libraries] androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -113,6 +114,7 @@ androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "s androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +aboutlibraries-compose = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutlibraries" } [plugins] androidTest = { id = "com.android.test", version.ref = "gradle" } @@ -125,3 +127,4 @@ kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } diff --git a/release-notes/v1.5.0.md b/release-notes/v1.5.0.md index 2b9cd103..cd91733b 100644 --- a/release-notes/v1.5.0.md +++ b/release-notes/v1.5.0.md @@ -1,58 +1,73 @@ # TypeType for Android 1.5.0 -TypeType for Android 1.5.0 brings a much more complete Shorts experience, -deeper player interactions and a long list of improvements across browsing, -comments, subscriptions and self-hosted instances. - -The application is still in beta. Thank you to everyone testing it, reporting -problems and helping improve the Android experience. - -## What is new - -- A rebuilt Shorts experience with smoother transitions, faster startup and - more reliable playback while scrolling. -- Swipe from a Short to open its channel, with clearer gesture feedback and - controls that remain available during the interaction. -- Copy titles, view engagement details and use more complete playback controls - directly from Shorts. -- Faster transitions between prepared videos through playback-session and - stream reuse. -- Comments are now available from the player quick actions. -- Load paginated comment replies, including nested conversations, with clearer - timestamps, likes, loading states and errors. -- More natural player gestures for seeking, volume and brightness, with saved - brightness preferences. -- Expanded fullscreen controls and more reliable restoration when moving - between embedded, expanded and fullscreen playback. -- Better handling of system bars, orientation changes and playback ending - while fullscreen is active. -- Smoother player expansion and collapse with continuous motion during drag - gestures. -- Subscription feeds refresh without hiding useful cached content during a - temporary failure. -- Stronger separation between accounts and instances when subscriptions are - cached or refreshed. -- More useful diagnostics when a subscription page cannot be loaded. -- Search, top-level navigation and restored application state now stay in sync - more reliably. -- Cleaner layouts for channels, playlists, podcasts, notifications, settings - and player details. -- Corrected playlist and video counts, live replay identification, shared URLs - and video menu state. -- TypeType can now trust certificate authorities installed by the user, making - private and locally hosted HTTPS instances easier to use. -- Refreshed authentication cookies are persisted before the application exits, - reducing unnecessary sign-ins after process recreation. -- New performance coverage for Shorts paging and playback prefetch behavior. - -Features provided by newer TypeType-Server versions appear automatically when -they are available. Older instances remain usable without showing unsupported -controls. - -TypeType for Android supports Android 6.0 through Android 17 and does not -require Google Play Services. - -If you encounter a problem, please open an issue in the -[TypeType-Android repository](https://github.com/TypeType-Video/TypeType-Android/issues). +TypeType for Android 1.5.0 brings a much more complete Shorts experience, deeper player interactions and improvements across comments, subscriptions, navigation and self-hosted instances. + +The application is still in beta. + +## Shorts + +- Rebuild Shorts playback with smoother transitions, faster startup and more reliable paging. +- Reuse prepared playback sessions and prefetched streams when moving between Shorts. +- Swipe from a Short to open its channel, with haptic feedback and controls that remain available during the gesture. +- Copy titles, view engagement details and use more complete playback controls directly from Shorts. +- Keep the active Short prioritized while preparing the next videos in the background. + +## Playback + +- Add more complete fullscreen controls and improve transitions between embedded, expanded and fullscreen playback. +- Restore the embedded player correctly after leaving expanded playback. +- Improve gestures for seeking, volume and brightness, and remember the selected brightness level. +- Restore system bars and orientation more reliably when playback ends or fullscreen closes. +- Add continuous player motion while dragging between expanded and minimized positions. +- Identify live replays from their stream metadata instead of presenting them as upcoming broadcasts. +- Normalize shared URLs, video metadata and player menu state. + +## Comments + +- Open comments directly from the player quick actions. +- Load paginated and nested replies without replacing the conversation already on screen. +- Improve comment timestamps, likes, interactive text, loading states and error presentation. + +## Subscriptions + +- Refresh subscription feeds without hiding useful cached content during temporary failures. +- Keep subscription caches and refresh generations separated between accounts and instances. +- Preserve typed Server errors and request IDs in subscription diagnostics. +- Add coverage for live-feed pagination, cache recreation and stale refresh protection. + +## Navigation And Interface + +- Keep Search, top-level navigation and restored application state in sync more reliably. +- Clean up layouts for channels, playlists, podcasts, notifications, settings and player details. +- Correct playlist and video counts and improve content metadata presentation. +- Refine settings hierarchy and channel discovery without hiding capabilities supported by the connected instance. + +## Self-Hosted Instances And Authentication + +- Trust certificate authorities installed by the user, making private and locally hosted HTTPS instances easier to use. +- Show a clearer connection error when an instance certificate cannot be trusted. +- Persist refreshed authentication cookies before process exit to reduce unnecessary sign-ins after process recreation. + +## Performance And Compatibility + +- Add Macrobenchmark coverage for Shorts paging and playback prefetch budgets. +- Keep newer Server capabilities discoverable at runtime while preserving compatibility with older TypeType-Server versions. +- Support Android 6.0 through Android 17 without requiring Google Play Services. + +## Thx + +Thx to @therealresonix for the detailed fullscreen, player motion, Search and navigation reports. + +Thx to @ollietheotter983 for reporting the subscription-feed problem and sharing diagnostics across different instances. + +Thx as well to everyone testing the Android beta, reporting bugs, sharing diagnostics and helping improve TypeType Android. + +## 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. + +If something does not work correctly, please open an issue in the [TypeType-Android repository](https://github.com/TypeType-Video/TypeType-Android/issues). + +If u want to support TypeType Android, please share it with others. If u want to support it financially, u can do so through [GitHub Sponsors](https://github.com/sponsors/Priveetee). **Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.4.0...v1.5.0 diff --git a/release-notes/v1.6.0.md b/release-notes/v1.6.0.md new file mode 100644 index 00000000..b3974990 --- /dev/null +++ b/release-notes/v1.6.0.md @@ -0,0 +1,82 @@ +# TypeType for Android 1.6.0 + +TypeType for Android 1.6.0 adds account portability, a complete appearance system, richer player gestures and a more efficient playback and startup path. + +The application is still in beta. + +## Playback + +- Show the active resolution, frame rate and codec instead of generic quality labels. [#34](https://github.com/TypeType-Video/TypeType-Android/issues/34) +- Select AV1, VP9 or H.264 from the decoders available on the device and fall back safely when a codec fails. [#35](https://github.com/TypeType-Video/TypeType-Android/issues/35) +- Add configurable three-zone double-tap actions for play, pause and seeking, including a custom seek duration. [#31](https://github.com/TypeType-Video/TypeType-Android/issues/31) [#36](https://github.com/TypeType-Video/TypeType-Android/issues/36) +- Morph the active player continuously between full screen, portrait and the mini-player while preserving playback state. +- Continue a downward full-screen gesture into the mini-player and restore the previous layout when the gesture is cancelled. +- Preserve full screen during autoplay, restore the player after Shorts and keep video visible in picture-in-picture. +- Adapt portrait controls, expose player actions to screen readers and improve buffering feedback. +- Redesign audio-only playback with a waveform derived from decoded audio. +- Keep playback allocations, abandoned SABR windows and comment caches bounded. + +## Data Portability + +- Add the unified export and import workspace backed by TypeType-Server. +- Support TypeType, PipePipe, NewPipe, Invidious, Piped, LibreTube, ViewTube, Materialious, Flow, SkyTube, Grayjay, YouTube Takeout and OPML data. +- Show source formats with their service icons, extensions and supported categories. +- Add Select all for exports while keeping individual categories available. +- Preview imports before applying them and choose Skip or Replace for duplicates. +- Show job progress, request IDs, downloadable artifacts and diagnostic reports. +- Prevent accidental navigation while a portability job is active. + +## Appearance + +- Add Classic and Manga interface personalities. +- Add TypeType, Dynamic, Nord, Cream, Forest and Plum color themes. +- Add Manga paper, headline and motion controls inspired by Komi Store while preserving the TypeType visual identity. +- Keep Manga text readable when the selected paper and system color mode differ. +- Preserve a pure-black AMOLED option without forcing light mode to remain dark. +- Apply appearance choices consistently across content, settings and shared surfaces. + +## Navigation And Interface + +- Open supported YouTube links directly in TypeType. [#37](https://github.com/TypeType-Video/TypeType-Android/issues/37) +- Create fresh channel state when navigating between uploaders. +- Restore the mini-player and portrait layout correctly after leaving Shorts. +- Add an open-source licenses screen with dependency and asset attribution. +- Simplify Player, Profile, Account, Privacy, Blocked and portability settings. +- Improve channel, podcast, playlist, search, history and notification surfaces. +- Keep typed errors, status codes and request IDs available when an operation fails. + +## Performance + +- Show the cached startup destination immediately and defer token and background preference initialization. +- Refresh the startup baseline profile and add startup memory measurements. +- Load blocked items, recommendations and comments incrementally. +- Bound decoded-image memory and reduce unnecessary recomposition during player transitions. +- Add rendered-frame benchmarks for player expansion, minimization and full-screen gestures. +- Resolve remote media through TypeType-Server consistently while keeping image requests bounded. + +## Compatibility + +- Support Android 6.0 through Android 17, API 23 through API 37. +- Keep core behavior independent from Google Play Services. +- Preserve baseline H.264/AAC playback and gate newer codecs by decoder capability. +- Keep extraction, SABR sessions, PO tokens, recommendations and synchronization owned by TypeType-Server. + +## Thx + +Thx to @therealresonix and @gursuj for the codec, playback-format and control feedback. [#31](https://github.com/TypeType-Video/TypeType-Android/issues/31) [#34](https://github.com/TypeType-Video/TypeType-Android/issues/34) [#35](https://github.com/TypeType-Video/TypeType-Android/issues/35) [#36](https://github.com/TypeType-Video/TypeType-Android/issues/36) + +Thx to @Alifoss for requesting direct YouTube-link handling. [#37](https://github.com/TypeType-Video/TypeType-Android/issues/37) + +Thx as well to everyone testing the beta builds, sharing logs, 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 u want to support TypeType Android, please share it with others. If u want to support it financially, u can do so through [GitHub Sponsors](https://github.com/sponsors/Priveetee). + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.5.0...v1.6.0