diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 9085ca1a..596fffb0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -42,6 +42,7 @@ env: ANDROID_SDK_ROOT: /opt/android-sdk JAVA_HOME: /usr/lib/jvm/java-26-openjdk GRADLE_USER_HOME: /var/tmp/typetype-android-gradle-user-home + TYPETYPE_SDK_PATH: .ci/TypeType-SDK jobs: verify: @@ -69,20 +70,34 @@ jobs: - name: Verify Android project timeout-minutes: 25 run: | - ./gradlew --build-cache --stacktrace \ - :baseline-profile:assemble \ - :player:testDebugUnitTest \ - :app:testDebugUnitTest \ - :player:lintDebug \ - :app:lintDebug \ - :app:assembleDebug \ + set -euo pipefail + tasks=( + :baseline-profile:assemble + :player:testDebugUnitTest + :app:testDebugUnitTest + :player:lintDebug + :app:lintDebug + :app:assembleDebug :app:assembleRelease + ) + if [[ -f "$TYPETYPE_SDK_PATH/settings.gradle.kts" ]]; then + tasks+=( + :tv:testDebugUnitTest + :tv:lintDebug + :tv:assembleDebug + ) + else + echo "::notice::TV verification skipped because the private TypeType-SDK checkout is unavailable." + fi + ./gradlew --build-cache --stacktrace "${tasks[@]}" - name: Upload debug APK uses: actions/upload-artifact@v7.0.1 with: name: typetype-debug-apk - path: app/build/outputs/apk/debug/*.apk + path: | + app/build/outputs/apk/debug/*.apk + tv/build/outputs/apk/debug/*.apk if-no-files-found: error compression-level: 0 retention-days: 14 @@ -95,6 +110,7 @@ jobs: path: | app/build/reports/lint-results-debug.html player/build/reports/lint-results-debug.html + tv/build/reports/lint-results-debug.html if-no-files-found: ignore retention-days: 14 @@ -156,14 +172,26 @@ jobs: - name: Verify Android project and build release timeout-minutes: 25 run: | - ./gradlew --build-cache --stacktrace \ - :baseline-profile:assemble \ - :player:testDebugUnitTest \ - :app:testDebugUnitTest \ - :player:lintDebug \ - :app:lintDebug \ - :app:assembleDebug \ + set -euo pipefail + tasks=( + :baseline-profile:assemble + :player:testDebugUnitTest + :app:testDebugUnitTest + :player:lintDebug + :app:lintDebug + :app:assembleDebug :app:assembleRelease + ) + if [[ -f "$TYPETYPE_SDK_PATH/settings.gradle.kts" ]]; then + tasks+=( + :tv:testDebugUnitTest + :tv:lintDebug + :tv:assembleDebug + ) + else + echo "::notice::TV verification skipped because the private TypeType-SDK checkout is unavailable." + fi + ./gradlew --build-cache --stacktrace "${tasks[@]}" - name: Upload lint report if: ${{ always() }} @@ -173,6 +201,7 @@ jobs: path: | app/build/reports/lint-results-debug.html player/build/reports/lint-results-debug.html + tv/build/reports/lint-results-debug.html if-no-files-found: ignore retention-days: 14 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 613b29fc..6b1a0d15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,8 @@ Gradle wrapper. ```sh git switch dev ./gradlew :app:assembleDebug +# Android TV +./gradlew :tv:assembleDebug ``` On first launch, the app asks for a TypeType instance. Use your own test @@ -67,6 +69,8 @@ the contract advertised by the selected TypeType instance. | `player` | TypeType playback integration for Media3 | | `app/src/test` | JVM unit and repository tests | | `app/src/androidTest` | Room, Compose, platform, and device tests | +| `tv/src/main` | Native Android TV UI, navigation, focus, and playback | +| `tv/src/test` | Android TV JVM unit tests | Most changes only touch one or two of these areas. As a rule of thumb, composables render state, repositories decide how data is loaded and cached, @@ -121,6 +125,15 @@ Before opening a pull request, run the same core checks as CI: :app:assembleRelease ``` +For TV changes, also run: + +```sh +./gradlew --no-daemon \ + :tv:testDebugUnitTest \ + :tv:lintDebug \ + :tv:assembleDebug +``` + Add the checks that make sense for your change. You are not expected to own every Android version or device; if something could not be tested, mention it in the pull request so another contributor can help. Useful checks include: diff --git a/README.md b/README.md index e6d38fb8..b6e6f867 100644 --- a/README.md +++ b/README.md @@ -21,73 +21,79 @@ -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. +TypeType Android uses the TypeType Server selected during setup for extraction, +playback sessions, recommendations, synchronization, and downloads. -The app communicates exclusively with the TypeType instance selected during -setup. Extraction, playback sessions, recommendations, synchronization, and -server-side downloads remain server responsibilities. +The Android TV client is part of this repository in the `tv` module. It is a +separate native TV application that uses the same TypeType SDK and server +contracts as the mobile client. ## Screenshots -| Home and Continue Watching | Search | Subscriptions | +| Welcome | Add an instance | Home | | --- | --- | --- | -| ![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) | +| ![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) | -| Library and history | Native player | Comments | +| Search | Native player | Settings | | --- | --- | --- | -| ![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) | - -| Notifications | Profile | 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. +- Use chapters, SponsorBlock, comments, related videos, the queue, and the + sleep timer. +- Download supported videos and import existing data. + +## Android TV -TypeType Android supports Android 6.0 and newer, and does not require Google -Play Services. +Build the TV client with the included Gradle wrapper: + +```sh +./gradlew :tv:assembleDebug +``` + +The TV module targets Android TV devices from API 23 onward and keeps its TV +navigation, focus behavior, layouts, and playback presentation independent from +the mobile UI. Local builds automatically use a sibling `TypeType-SDK` +checkout; set `TYPETYPE_SDK_PATH` when the SDK is stored elsewhere. ## 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 +105,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 +113,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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 710370c6..35a4f7e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10704 - versionName = "1.7.0-beta.5" + versionCode = 10705 + versionName = "1.7.0-beta.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt new file mode 100644 index 00000000..c1294a95 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/theme/TypeTypeThemeOrderTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.android.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import dev.typetype.android.domain.preferences.AccentColor +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 org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class TypeTypeThemeOrderTest { + @get:Rule + val composeRule = createAndroidComposeRule() + private val preferences = mutableStateOf(AppPreferences()) + private var scheme: ColorScheme? = null + + @Test + fun oledKeepsDynamicPaletteAndReplacesOnlySurfaces() { + preferences.value = AppPreferences( + accentColor = AccentColor.System, + appearancePersonality = AppearancePersonality.Classic, + appearanceMode = AppearanceMode.Dark, + appearanceTheme = AppearanceTheme.Dynamic, + ) + composeRule.setContent { + TypeTypeTheme(preferences = preferences.value) { + scheme = MaterialTheme.colorScheme + } + } + val dynamic = requireNotNull(scheme) + val oled = colorSchemeFor( + preferences.value.copy(appearanceAmoled = true), + ) + assertEquals(dynamic.primary, oled.primary) + assertEquals(dynamic.secondaryContainer, oled.secondaryContainer) + assertEquals(dynamic.tertiary, oled.tertiary) + assertEquals(dynamic.error, oled.error) + assertEquals(dynamic.surfaceVariant, oled.surfaceVariant) + assertEquals(dynamic.outline, oled.outline) + assertEquals(Color.Black, oled.background) + assertEquals(Color.Black, oled.surface) + } + + private fun colorSchemeFor(preferences: AppPreferences): ColorScheme { + scheme = null + composeRule.runOnIdle { + this.preferences.value = preferences + } + composeRule.waitForIdle() + return requireNotNull(scheme) + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt index 3a3294b8..5f611cc4 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycleTest.kt @@ -4,6 +4,8 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.lifecycle.Lifecycle import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -12,7 +14,7 @@ class PlayerSurfaceLifecycleTest { val composeRule = createAndroidComposeRule() @Test - fun activeLifecycleDoesNotRecreateSurfaceUntilARealStop() { + fun pausedBackgroundCyclePreservesSurfaceWithoutScreenOff() { var surfaceKey = "" composeRule.setContent { surfaceKey = rememberPlayerSurfaceKey("video") @@ -26,7 +28,17 @@ class PlayerSurfaceLifecycleTest { composeRule.activityRule.scenario.moveToState(Lifecycle.State.RESUMED) composeRule.runOnIdle { - assertEquals("video:1", surfaceKey) + assertEquals("video:0", surfaceKey) } } + + @Test + fun onlyScreenOffRequestsSurfaceRefresh() { + val gate = PlayerSurfaceRefreshGate() + + assertFalse(gate.consumeScreenOff()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) + } } 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 index 2a4d26a5..c7913edc 100644 --- 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 @@ -87,6 +87,18 @@ internal fun themedDarkScheme( ) } +internal fun ColorScheme.withAmoledSurfaces(): ColorScheme = copy( + background = Color.Black, + surface = Color.Black, + surfaceContainerLowest = Color.Black, + surfaceContainerLow = Color.Black, + surfaceContainer = Color.Black, + surfaceContainerHigh = Color.Black, + surfaceContainerHighest = Color.Black, + surfaceDim = Color.Black, + surfaceBright = Color.Black, +) + internal fun themedLightScheme( theme: AppearanceTheme, accent: Color, 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 153524c9..a91085c3 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 @@ -43,6 +43,11 @@ fun TypeTypeTheme( (preferences.appearanceTheme == AppearanceTheme.Dynamic || (preferences.appearanceTheme == AppearanceTheme.TypeType && preferences.accentColor == AccentColor.System)) + val dynamicScheme = when { + dynamic && dark -> dynamicDarkColorScheme(context) + dynamic -> dynamicLightColorScheme(context) + else -> null + } val colorScheme = when { preferences.appearancePersonality == AppearancePersonality.Manga -> mangaScheme( @@ -53,14 +58,8 @@ fun TypeTypeTheme( amoled = effectiveAmoled, isDark = dark, ) - dark && effectiveAmoled -> themedDarkScheme( - preferences.appearanceTheme, - accent, - accentSoft, - amoled = effectiveAmoled, - ) - dynamic && dark -> dynamicDarkColorScheme(context) - dynamic -> dynamicLightColorScheme(context) + dynamicScheme != null && effectiveAmoled -> dynamicScheme.withAmoledSurfaces() + dynamicScheme != null -> dynamicScheme dark -> themedDarkScheme( preferences.appearanceTheme, accent, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt index bf26c946..86e41ca8 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceLifecycle.kt @@ -25,8 +25,9 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { DisposableEffect(lifecycleOwner, streamId) { val observer = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_STOP -> refreshGate.invalidate() - Lifecycle.Event.ON_START -> if (refreshGate.refresh()) epoch += 1 + Lifecycle.Event.ON_START -> if (refreshGate.consumeScreenOff()) { + epoch += 1 + } else -> Unit } } @@ -37,12 +38,12 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - Intent.ACTION_SCREEN_OFF -> refreshGate.invalidate() + Intent.ACTION_SCREEN_OFF -> refreshGate.markScreenOff() Intent.ACTION_SCREEN_ON, Intent.ACTION_USER_PRESENT, -> if ( lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && - refreshGate.refresh() + refreshGate.consumeScreenOff() ) { epoch += 1 } @@ -66,15 +67,15 @@ internal fun rememberPlayerSurfaceKey(streamId: String): String { } internal class PlayerSurfaceRefreshGate { - private var invalid = false + private var screenOff = false - fun invalidate() { - invalid = true + fun markScreenOff() { + screenOff = true } - fun refresh(): Boolean { - if (!invalid) return false - invalid = false + fun consumeScreenOff(): Boolean { + if (!screenOff) return false + screenOff = false return true } } diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt index 392fb07a..23d39da7 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerSurfaceRefreshGateTest.kt @@ -6,31 +6,30 @@ import org.junit.Test class PlayerSurfaceRefreshGateTest { @Test - fun `initial lifecycle events do not recreate the surface`() { + fun `background cycle without screen off does not request refresh`() { val gate = PlayerSurfaceRefreshGate() - assertFalse(gate.refresh()) - assertFalse(gate.refresh()) + assertFalse(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } @Test - fun `multiple wake signals recreate the surface once`() { + fun `screen off requests refresh once`() { val gate = PlayerSurfaceRefreshGate() - gate.invalidate() - assertTrue(gate.refresh()) - assertFalse(gate.refresh()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } @Test - fun `each inactive cycle permits one surface recreation`() { + fun `each screen off cycle permits one surface recreation`() { val gate = PlayerSurfaceRefreshGate() repeat(1_000) { - gate.invalidate() - gate.invalidate() - assertTrue(gate.refresh()) - assertFalse(gate.refresh()) + gate.markScreenOff() + assertTrue(gate.consumeScreenOff()) + assertFalse(gate.consumeScreenOff()) } } } 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/release-notes/v1.6.0.md b/release-notes/v1.6.0.md index 596fe5b4..b3974990 100644 --- a/release-notes/v1.6.0.md +++ b/release-notes/v1.6.0.md @@ -1,66 +1,80 @@ # TypeType for Android 1.6.0 -TypeType for Android 1.6.0 focuses on safer data portability, clearer playback information and smoother high-volume screens. +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 flow backed by TypeType-Server. -- Show supported formats with service icons, source names and file extensions. -- Add category selection with support status, counts and compatibility details. -- Add Select all for Export while keeping individual categories available. -- Preview imports before applying them. -- Add Skip and Replace duplicate policies during Import. -- Add progress reporting, request IDs, artifact download and diagnostic reports. -- Block accidental backward navigation while a portability job is running. -- Use official format assets where they are available. +- 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. -- Preserve a pure black AMOLED variant without forcing Light mode to stay dark. -- Improve theme updates across settings so changing between black and white applies immediately. -- Make switches more visible across Player, Privacy, Content, RSS and Account settings. - -## Playback - -- Make the audio-only transition respond immediately instead of waiting for every playback operation to finish. -- Rework the audio-only waveform to match the Frontend visual style. -- Normalize displayed quality names and keep HDR variants readable. -- Expose the active codec and resolution instead of using generic placeholder labels. -- Improve codec fallback behavior based on device decoder support. - -## Lists And Performance - -- Load blocked channels and videos incrementally so large blocklists remain responsive. -- Load additional recommendations progressively as you scroll. -- Reduce unnecessary recomposition during player expansion and minimization. -- Resolve media through TypeType-Server more consistently and keep images inside bounded requests. -- Improve startup and paging benchmarks while keeping peak heap and native memory stable during transitions. - -## Interface And Settings - -- Simplify Player, Profile, Account, Privacy, Blocked and Data-portability settings. -- Keep useful details such as typed errors, status codes and request IDs when failures occur. -- Remove ambiguous controls and duplicate actions. +- 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 without Google Play Services. -- The Android client remains dependent on TypeType-Server for extraction, SABR sessions, PO tokens, recommendations and synchronization. +- 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 playback-format and control feedback. +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 1.6.0 on the beta instance, sharing logs and reporting regressions. +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). diff --git a/release-notes/v1.6.1.md b/release-notes/v1.6.1.md new file mode 100644 index 00000000..bb29a630 --- /dev/null +++ b/release-notes/v1.6.1.md @@ -0,0 +1,26 @@ +# TypeType for Android 1.6.1 + +TypeType for Android 1.6.1 is a corrective release for playback seeking. + +The application is still in beta. + +## Playback + +- Coalesce rapid repeated seek requests while keeping the first seek immediate. +- Cancel obsolete SABR window loads and apply only the final requested position. +- Keep playback from advancing through several pending seek generations during rapid scrubbing. + +## Validation + +- Full Android unit-test, lint, baseline-profile and debug/release build matrix completed successfully. +- The corrective path was validated in the `1.7.0-beta.5` channel after rapid repeated seeks. + +## Thx + +Thx to everyone testing TypeType Android and reporting playback problems. + +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.6.0...v1.6.1 diff --git a/settings.gradle.kts b/settings.gradle.kts index e44b31dc..ef5d1b5b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,3 +25,22 @@ rootProject.name = "TypeType-Android" include(":app") include(":baseline-profile") include(":player") +include(":tv") + +val typeTypeSdkPath = providers.gradleProperty("typeTypeSdkPath") + .orElse(providers.environmentVariable("TYPETYPE_SDK_PATH")) + .orElse("../TypeType-SDK") + .get() +val typeTypeSdkDirectory = file(typeTypeSdkPath) +if (typeTypeSdkDirectory.resolve("settings.gradle.kts").isFile) { + includeBuild(typeTypeSdkDirectory) { + dependencySubstitution { + substitute(module("video.typetype:sdk-core")) + .using(project(":sdk-core")) + substitute(module("video.typetype:sdk-android")) + .using(project(":sdk-android")) + substitute(module("video.typetype:sdk-media3")) + .using(project(":sdk-media3")) + } + } +} diff --git a/tv/README.md b/tv/README.md new file mode 100644 index 00000000..e3757b40 --- /dev/null +++ b/tv/README.md @@ -0,0 +1,19 @@ +# TypeType TV + +This module is the native Android TV client for TypeType. It owns TV-specific +layouts, focus navigation, remote-key interaction, playback presentation, and +TV appearance choices. Shared server contracts, authentication, sessions, +network policy, and Media3 adapters come from TypeType-SDK. + +## Local build + +From the repository root: + +```sh +./gradlew :tv:testDebugUnitTest :tv:lintDebug :tv:assembleDebug +``` + +The Gradle settings use `../TypeType-SDK` when that checkout exists. Set +`TYPETYPE_SDK_PATH` to another local checkout when needed. The debug build uses +the configured beta instance by default; do not place credentials in the +repository. diff --git a/tv/build.gradle.kts b/tv/build.gradle.kts new file mode 100644 index 00000000..1264c876 --- /dev/null +++ b/tv/build.gradle.kts @@ -0,0 +1,202 @@ +import java.security.MessageDigest +import java.net.URI + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.compose.compiler) +} + +val releaseInstanceUrl = providers.gradleProperty("typeTypeReleaseInstanceUrl") + .orElse(providers.environmentVariable("TYPETYPE_RELEASE_INSTANCE_URL")) + .orElse("") +val releaseStoreFile = providers.gradleProperty("typeTypeReleaseStoreFile") + .orElse(providers.environmentVariable("TYPETYPE_RELEASE_STORE_FILE")) + .orElse("") +val releaseStorePassword = providers.gradleProperty("typeTypeReleaseStorePassword") + .orElse(providers.environmentVariable("TYPETYPE_RELEASE_STORE_PASSWORD")) + .orElse("") +val releaseKeyAlias = providers.gradleProperty("typeTypeReleaseKeyAlias") + .orElse(providers.environmentVariable("TYPETYPE_RELEASE_KEY_ALIAS")) + .orElse("") +val releaseKeyPassword = providers.gradleProperty("typeTypeReleaseKeyPassword") + .orElse(providers.environmentVariable("TYPETYPE_RELEASE_KEY_PASSWORD")) + .orElse("") +val sdkVersion = providers.gradleProperty("typeTypeSdkVersion") + .orElse(providers.environmentVariable("TYPETYPE_SDK_VERSION")) + .orElse("0.1.0-SNAPSHOT") +val useLocalSdk = providers.gradleProperty("useLocalSdk") + .orElse(providers.environmentVariable("TYPETYPE_USE_LOCAL_SDK")) + .map { value -> + when (value.trim().lowercase()) { + "true" -> true + "false" -> false + else -> error("TYPETYPE_USE_LOCAL_SDK must be true or false") + } + } + .orElse(true) + +tasks.register("verifyReleaseInstanceConfiguration") { + doLast { + val url = releaseInstanceUrl.get().trim() + require(url.isNotBlank()) { + "Release builds require -PtypeTypeReleaseInstanceUrl or TYPETYPE_RELEASE_INSTANCE_URL" + } + require(url.startsWith("https://", ignoreCase = true)) { + "Release instance URL must use HTTPS" + } + val parsed = runCatching { URI(url) }.getOrNull() + ?: error("Release instance URL is not a valid URI") + require(!parsed.host.isNullOrBlank()) { "Release instance URL must include a host" } + require(parsed?.query == null && parsed.fragment == null) { + "Release instance URL must not include a query or fragment" + } + require(!parsed.host.equals("beta.typetype.video", ignoreCase = true)) { + "Release instance URL must not point to the beta instance" + } + require(!useLocalSdk.get()) { + "Release builds require -PuseLocalSdk=false and a published SDK" + } + val version = sdkVersion.get().trim() + require(version.matches(Regex("\\d+\\.\\d+\\.\\d+(?:[-.][0-9A-Za-z.-]+)?"))) { + "Release builds require an immutable semantic typeTypeSdkVersion" + } + require(!version.endsWith("-SNAPSHOT", ignoreCase = true)) { + "Release builds require an immutable typeTypeSdkVersion" + } + } +} + +tasks.register("verifyReleaseSigningConfiguration") { + doLast { + require(releaseStoreFile.get().isNotBlank()) { + "Release builds require -PtypeTypeReleaseStoreFile or TYPETYPE_RELEASE_STORE_FILE" + } + require(file(releaseStoreFile.get()).isFile) { "The configured release keystore does not exist" } + require(releaseStorePassword.get().isNotBlank()) { + "Release builds require -PtypeTypeReleaseStorePassword or TYPETYPE_RELEASE_STORE_PASSWORD" + } + require(releaseKeyAlias.get().isNotBlank()) { + "Release builds require -PtypeTypeReleaseKeyAlias or TYPETYPE_RELEASE_KEY_ALIAS" + } + require(releaseKeyPassword.get().isNotBlank()) { + "Release builds require -PtypeTypeReleaseKeyPassword or TYPETYPE_RELEASE_KEY_PASSWORD" + } + } +} + +android { + namespace = "video.typetype.tv" + compileSdk = 37 + + defaultConfig { + applicationId = "video.typetype.tv" + minSdk = 23 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + } + + signingConfigs { + create("release") { + if (releaseStoreFile.get().isNotBlank()) storeFile = file(releaseStoreFile.get()) + if (releaseStorePassword.get().isNotBlank()) storePassword = releaseStorePassword.get() + if (releaseKeyAlias.get().isNotBlank()) keyAlias = releaseKeyAlias.get() + if (releaseKeyPassword.get().isNotBlank()) keyPassword = releaseKeyPassword.get() + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + isDebuggable = true + val instanceUrl = providers.gradleProperty("typeTypeInstanceUrl") + .orElse(providers.environmentVariable("TYPETYPE_INSTANCE_URL")) + .orElse("https://beta.typetype.video/api") + buildConfigField("String", "TYPETYPE_INSTANCE_URL", instanceUrl.get().toBuildConfigString()) + } + getByName("release") { + isMinifyEnabled = true + signingConfig = signingConfigs.getByName("release") + buildConfigField("String", "TYPETYPE_INSTANCE_URL", releaseInstanceUrl.get().toBuildConfigString()) + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +private fun String.toBuildConfigString(): String = + "\"${replace("\\", "\\\\").replace("\"", "\\\"")}\"" + +tasks.matching { it.name == "preReleaseBuild" }.configureEach { + dependsOn("verifyReleaseInstanceConfiguration") + dependsOn("verifyReleaseSigningConfiguration") +} + +tasks.register("verifyReleaseArtifact") { + dependsOn("assembleRelease") + doLast { + val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile + require(apk.isFile) { "The signed release APK was not produced" } + val sdkRoot = providers.environmentVariable("ANDROID_HOME").orNull + ?: providers.environmentVariable("ANDROID_SDK_ROOT").orNull + ?: error("ANDROID_HOME or ANDROID_SDK_ROOT is required") + val buildTools = file("$sdkRoot/build-tools").listFiles() + ?.filter { it.isDirectory && File(it, "apksigner").isFile } + ?.maxByOrNull { it.name } + ?: error("No Android build-tools installation with apksigner was found") + val apksigner = ProcessBuilder( + File(buildTools, "apksigner").absolutePath, + "verify", + "--verbose", + apk.absolutePath, + ).inheritIO().start() + require(apksigner.waitFor() == 0) { "apksigner rejected the release APK" } + val digest = MessageDigest.getInstance("SHA-256") + .digest(apk.readBytes()) + .joinToString("") { byte -> "%02x".format(byte) } + val report = layout.buildDirectory.file("verification/release-sha256.txt").get().asFile + report.parentFile.mkdirs() + report.writeText("$digest ${apk.name}\n") + println("Verified signed release APK: ${apk.path}") + println("SHA-256 report: ${report.path}") + } +} + +dependencies { + implementation("video.typetype:sdk-android:${sdkVersion.get()}") + implementation("video.typetype:sdk-media3:${sdkVersion.get()}") + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation("androidx.tv:tv-material:1.0.0") + + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.lifecycle.viewmodel) + implementation(libs.androidx.datastore.preferences) + + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) + implementation(libs.androidx.media3.ui) + + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + + testImplementation(libs.junit) +} diff --git a/tv/proguard-rules.pro b/tv/proguard-rules.pro new file mode 100644 index 00000000..76d94b45 --- /dev/null +++ b/tv/proguard-rules.pro @@ -0,0 +1 @@ +# TypeType SDK models are consumed through public APIs and kept by R8 when referenced. diff --git a/tv/src/main/AndroidManifest.xml b/tv/src/main/AndroidManifest.xml new file mode 100644 index 00000000..5700e8ec --- /dev/null +++ b/tv/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tv/src/main/java/video/typetype/tv/MainActivity.kt b/tv/src/main/java/video/typetype/tv/MainActivity.kt new file mode 100644 index 00000000..fe4f7cca --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/MainActivity.kt @@ -0,0 +1,62 @@ +package video.typetype.tv + +import android.os.Bundle +import android.content.Intent +import android.net.Uri +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import video.typetype.tv.data.TypeTypeTvClient +import video.typetype.tv.data.TvViewModel +import video.typetype.tv.data.TvArtifactStore +import video.typetype.tv.data.TvDownloadStateStore +import video.typetype.tv.data.handleOidcCallback +import video.typetype.tv.player.TvPlaybackCodecSupport + +public class MainActivity : ComponentActivity() { + private val viewModel: TvViewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (!modelClass.isAssignableFrom(TvViewModel::class.java)) { + throw IllegalArgumentException("Unsupported ViewModel: ${modelClass.name}") + } + return requireNotNull( + modelClass.cast( + TvViewModel( + TypeTypeTvClient.create(this@MainActivity, BuildConfig.TYPETYPE_INSTANCE_URL), + TvArtifactStore(this@MainActivity), + TvDownloadStateStore(this@MainActivity), + TvPlaybackCodecSupport(this@MainActivity)::isVideoSupported, + ), + ), + ) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + window.decorView.systemUiVisibility = ( + android.view.View.SYSTEM_UI_FLAG_FULLSCREEN or + android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + setContent { + TypeTypeTvApp( + viewModel = viewModel, + onOpenOidc = { url -> startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }, + ) + } + intent?.data?.let(viewModel::handleOidcCallback) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + intent.data?.let(viewModel::handleOidcCallback) + } +} diff --git a/tv/src/main/java/video/typetype/tv/TypeTypeTvApp.kt b/tv/src/main/java/video/typetype/tv/TypeTypeTvApp.kt new file mode 100644 index 00000000..0da4360a --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/TypeTypeTvApp.kt @@ -0,0 +1,165 @@ +package video.typetype.tv + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import video.typetype.tv.data.TvViewModel +import video.typetype.tv.data.beginOidc +import video.typetype.tv.data.closeCollection +import video.typetype.tv.data.openChannel +import video.typetype.tv.data.openPlaylist +import video.typetype.tv.data.openSavedPlaylist +import video.typetype.tv.data.openUserPlaylist +import video.typetype.tv.data.playNext +import video.typetype.tv.data.playQueuedVideo +import video.typetype.tv.data.switchVideoTrack +import video.typetype.tv.data.switchAudioTrack +import video.typetype.tv.data.switchSubtitle +import video.typetype.tv.data.openPodcast +import video.typetype.tv.data.openVideo +import video.typetype.tv.data.playVideo +import video.typetype.tv.data.closePodcast +import video.typetype.tv.data.loadMoreChannel +import video.typetype.tv.data.loadMorePlaylist +import video.typetype.tv.data.loadMorePodcast +import video.typetype.tv.data.toggleFavorite +import video.typetype.tv.data.toggleWatchLater +import video.typetype.tv.data.toggleSubscription +import video.typetype.tv.data.toggleSavedPlaylist +import video.typetype.tv.data.togglePlaylistVideo +import video.typetype.tv.data.loadMoreComments +import video.typetype.tv.data.loadCommentReplies +import video.typetype.tv.data.updateSearchQuery +import video.typetype.tv.data.selectSearchContentFilter +import video.typetype.tv.data.selectSearchSortFilter +import video.typetype.tv.data.toggleSearchFilter +import video.typetype.tv.data.search +import video.typetype.tv.data.loadMoreSearch +import video.typetype.tv.data.startAudioOnlyPlayback +import video.typetype.tv.data.createPlaylist +import video.typetype.tv.data.renamePlaylist +import video.typetype.tv.data.deletePlaylist +import video.typetype.tv.data.removePlaylistVideo +import video.typetype.tv.data.movePlaylistVideo +import video.typetype.tv.data.TvPlaylistActions +import video.typetype.tv.data.TvProfileActions +import video.typetype.tv.data.TvSubscriptionGroupActions +import video.typetype.tv.data.updateProfile +import video.typetype.tv.data.updateSettings +import video.typetype.tv.data.setEmojiAvatar +import video.typetype.tv.data.clearAvatar +import video.typetype.tv.data.clearHistory +import video.typetype.tv.data.selectSubscriptionGroup +import video.typetype.tv.data.createSubscriptionGroup +import video.typetype.tv.data.renameSubscriptionGroup +import video.typetype.tv.data.deleteSubscriptionGroup +import video.typetype.tv.data.toggleSubscriptionGroupChannel +import video.typetype.tv.data.startDownload +import video.typetype.tv.data.cancelDownload +import video.typetype.tv.data.retryDownloadArtifact +import video.typetype.tv.data.clearDownload +import video.typetype.tv.ui.TvRoot +import video.typetype.tv.ui.theme.TvAppearance +import video.typetype.tv.ui.theme.TypeTypeTvTheme +import video.typetype.tv.ui.theme.TvAppearanceStore + +@Composable +public fun TypeTypeTvApp( + viewModel: TvViewModel, + onOpenOidc: (String) -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val appearanceStore = remember(context) { TvAppearanceStore(context) } + val appearance by appearanceStore.appearance.collectAsStateWithLifecycle(TvAppearance()) + val scope = rememberCoroutineScope() + val playlistActions = remember(viewModel) { + TvPlaylistActions( + create = viewModel::createPlaylist, + rename = viewModel::renamePlaylist, + delete = viewModel::deletePlaylist, + removeVideo = viewModel::removePlaylistVideo, + moveVideo = viewModel::movePlaylistVideo, + ) + } + val profileActions = remember(viewModel) { + TvProfileActions( + update = viewModel::updateProfile, + setEmojiAvatar = viewModel::setEmojiAvatar, + clearAvatar = viewModel::clearAvatar, + ) + } + val subscriptionGroupActions = remember(viewModel) { + TvSubscriptionGroupActions( + select = viewModel::selectSubscriptionGroup, + create = viewModel::createSubscriptionGroup, + rename = viewModel::renameSubscriptionGroup, + delete = viewModel::deleteSubscriptionGroup, + toggleChannel = viewModel::toggleSubscriptionGroupChannel, + ) + } + TypeTypeTvTheme(appearance) { + TvRoot( + state = state, + appearance = appearance, + onAppearanceChange = { value -> scope.launch { appearanceStore.save(value) } }, + onSettingsChange = viewModel::updateSettings, + onNavigate = viewModel::navigate, + onServiceChange = viewModel::selectService, + onLogin = viewModel::login, + onRegister = viewModel::register, + onOidc = { viewModel.beginOidc(onOpenOidc) }, + onContinueAsGuest = viewModel::continueAsGuest, + onLogout = viewModel::logout, + onPlayVideo = viewModel::playVideo, + onOpenVideo = viewModel::openVideo, + onOpenChannel = viewModel::openChannel, + onOpenPlaylist = viewModel::openPlaylist, + onOpenUserPlaylist = viewModel::openUserPlaylist, + onOpenSavedPlaylist = viewModel::openSavedPlaylist, + onOpenPodcast = viewModel::openPodcast, + onToggleFavorite = viewModel::toggleFavorite, + onToggleWatchLater = viewModel::toggleWatchLater, + onToggleSubscription = viewModel::toggleSubscription, + onToggleSavedPlaylist = viewModel::toggleSavedPlaylist, + onClearHistory = viewModel::clearHistory, + onTogglePlaylistVideo = viewModel::togglePlaylistVideo, + onStartDownload = viewModel::startDownload, + onCancelDownload = viewModel::cancelDownload, + onRetryDownloadArtifact = viewModel::retryDownloadArtifact, + onClearDownload = viewModel::clearDownload, + playlistActions = playlistActions, + profileActions = profileActions, + subscriptionGroupActions = subscriptionGroupActions, + onLoadMoreChannel = viewModel::loadMoreChannel, + onLoadMorePlaylist = viewModel::loadMorePlaylist, + onLoadMorePodcast = viewModel::loadMorePodcast, + onStartPlayback = viewModel::startPlayback, + onStartAudioPlayback = viewModel::startAudioOnlyPlayback, + onSelectVideoTrack = viewModel::selectVideoTrack, + onSelectAudioTrack = viewModel::selectAudioTrack, + onSelectSubtitle = viewModel::selectSubtitle, + onPlayNext = viewModel::playNext, + onPlayQueueItem = viewModel::playQueuedVideo, + onSelectVideoTrackDuringPlayback = viewModel::switchVideoTrack, + onSelectAudioTrackDuringPlayback = viewModel::switchAudioTrack, + onSelectSubtitleDuringPlayback = viewModel::switchSubtitle, + onLoadMoreComments = viewModel::loadMoreComments, + onLoadCommentReplies = viewModel::loadCommentReplies, + onSearch = viewModel::search, + onSearchQueryChange = viewModel::updateSearchQuery, + onSearchContentFilter = viewModel::selectSearchContentFilter, + onSearchSortFilter = viewModel::selectSearchSortFilter, + onToggleSearchFilter = viewModel::toggleSearchFilter, + onLoadMoreSearch = viewModel::loadMoreSearch, + onClosePlayback = viewModel::closePlayback, + onCloseDetails = viewModel::closeDetails, + onCloseCollection = viewModel::closeCollection, + onClosePodcast = viewModel::closePodcast, + ) + } +} diff --git a/tv/src/main/java/video/typetype/tv/data/PlaylistOrdering.kt b/tv/src/main/java/video/typetype/tv/data/PlaylistOrdering.kt new file mode 100644 index 00000000..b254d34c --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/PlaylistOrdering.kt @@ -0,0 +1,11 @@ +package video.typetype.tv.data + +internal fun reorderedVideoIds(ids: List, selectedId: String, offset: Int): List? { + val source = ids.indexOf(selectedId) + if (source < 0 || ids.isEmpty()) return null + val destination = (source + offset).coerceIn(0, ids.lastIndex) + if (source == destination) return null + return ids.toMutableList().apply { + add(destination, removeAt(source)) + } +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvArtifactStore.kt b/tv/src/main/java/video/typetype/tv/data/TvArtifactStore.kt new file mode 100644 index 00000000..411aafe7 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvArtifactStore.kt @@ -0,0 +1,140 @@ +package video.typetype.tv.data + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import java.io.File +import java.io.FileOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import video.typetype.sdk.core.DownloadArtifact +import video.typetype.sdk.core.DownloadJob +import video.typetype.sdk.core.DownloaderApi +import video.typetype.sdk.core.TypeTypeByteSink +import video.typetype.sdk.core.TypeTypeResult + +public data class TvSavedArtifact( + val fileName: String, + val location: String, +) + +public class TvArtifactStore( + context: Context, +) { + private val appContext = context.applicationContext + + public suspend fun save(api: DownloaderApi, job: DownloadJob): Result = withContext(Dispatchers.IO) { + runCatching { + val temporaryDirectory = File(appContext.noBackupFilesDir, "download-artifacts").apply { mkdirs() } + val temporary = File(temporaryDirectory, "${safeJobId(job.id)}.part") + try { + val artifact = downloadArtifact(api, job.id, temporary) + publish(temporary, job, artifact) + } finally { + temporary.delete() + } + } + } + + private suspend fun downloadArtifact(api: DownloaderApi, jobId: String, target: File): DownloadArtifact { + val existingBytes = target.length().takeIf { it > 0L } + val artifact = appendArtifact(api, jobId, target, existingBytes) + if (existingBytes != null && artifact.status != 206) { + target.delete() + return appendArtifact(api, jobId, target, null) + } + return artifact + } + + private suspend fun appendArtifact( + api: DownloaderApi, + jobId: String, + target: File, + rangeStart: Long?, + ): DownloadArtifact = FileOutputStream(target, rangeStart != null).use { output -> + when (val result = api.artifact( + jobId, + TypeTypeByteSink { bytes, offset, length -> output.write(bytes, offset, length) }, + rangeStart, + )) { + is TypeTypeResult.Success -> result.value + is TypeTypeResult.Failure -> error(result.error.toUserMessage()) + } + } + + private fun publish(source: File, job: DownloadJob, artifact: DownloadArtifact): TvSavedArtifact { + val fileName = safeFileName( + artifact.fileName ?: job.resolved?.fileName ?: defaultFileName(job), + ) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + publishToMediaStore(source, fileName, artifact.contentType) + } else { + publishToAppDownloads(source, fileName) + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun publishToMediaStore(source: File, fileName: String, contentType: String?): TvSavedArtifact { + val resolver = appContext.contentResolver + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, contentType ?: "application/octet-stream") + put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/TypeType") + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = requireNotNull(resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)) { + "Android could not create the download destination" + } + try { + requireNotNull(resolver.openOutputStream(uri, "w")) { + "Android could not open the download destination" + }.use { output -> source.inputStream().use { input -> input.copyTo(output) } } + resolver.update(uri, ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }, null, null) + return TvSavedArtifact(fileName, "Downloads/TypeType") + } catch (exception: Exception) { + resolver.delete(uri, null, null) + throw exception + } + } + + private fun publishToAppDownloads(source: File, fileName: String): TvSavedArtifact { + val root = requireNotNull(appContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)) { + "Android external storage is unavailable" + } + val directory = File(root, "TypeType").apply { mkdirs() } + val destination = uniqueFile(directory, fileName) + source.inputStream().use { input -> destination.outputStream().use { output -> input.copyTo(output) } } + return TvSavedArtifact(destination.name, "TypeType downloads") + } +} + +private fun safeJobId(value: String): String = value.replace(Regex("[^A-Za-z0-9._-]"), "_").take(120) + +private fun defaultFileName(job: DownloadJob): String { + val title = job.title?.takeIf(String::isNotBlank) ?: "TypeType download" + val extension = job.resolved?.container?.takeIf(String::isNotBlank) ?: "bin" + return "$title.$extension" +} + +private fun safeFileName(value: String): String = value.trim() + .replace(Regex("[\\/:*?\"<>|\\p{Cntrl}]"), "_") + .trim('.', ' ') + .take(180) + .ifBlank { "TypeType download.bin" } + +private fun uniqueFile(directory: File, fileName: String): File { + val initial = File(directory, fileName) + if (!initial.exists()) return initial + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + val base = if (extension.isEmpty()) fileName else fileName.removeSuffix(".$extension") + var suffix = 2 + while (true) { + val candidateName = if (extension.isEmpty()) "$base ($suffix)" else "$base ($suffix).$extension" + val candidate = File(directory, candidateName) + if (!candidate.exists()) return candidate + suffix += 1 + } +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvAudioPlayback.kt b/tv/src/main/java/video/typetype/tv/data/TvAudioPlayback.kt new file mode 100644 index 00000000..83c33f94 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvAudioPlayback.kt @@ -0,0 +1,38 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import video.typetype.sdk.core.PlaybackSession +import video.typetype.sdk.core.TypeTypeResult + +public fun TvViewModel.startAudioOnlyPlayback() { + val video = mutableState.value.selectedVideo ?: return + val stream = mutableState.value.stream ?: return + viewModelScope.launch { + mutableState.value = mutableState.value.copy(isLoadingDetails = true, errorMessage = null) + when (val result = client.catalog.audioOnly(video.url)) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + audioOnlyStream = result.value, + playback = PlaybackSession( + sessionId = "audio-${stream.id.value}", + videoId = stream.id, + formats = emptyList(), + audioTracks = emptyList(), + subtitles = emptyList(), + isLive = stream.isLive, + ready = true, + status = "ready", + startTimeMilliseconds = stream.startPositionMilliseconds, + durationMilliseconds = result.value.durationSeconds?.times(1_000L) + ?: stream.durationSeconds.times(1_000L), + transport = "audio-only", + ), + isLoadingDetails = false, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isLoadingDetails = false, + errorMessage = result.error.toUserMessage(), + ) + } + } +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvCollectionPaging.kt b/tv/src/main/java/video/typetype/tv/data/TvCollectionPaging.kt new file mode 100644 index 00000000..f2478596 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvCollectionPaging.kt @@ -0,0 +1,142 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.async +import video.typetype.sdk.core.ChannelRequest +import video.typetype.sdk.core.PlaylistRequest +import video.typetype.sdk.core.Podcast +import video.typetype.sdk.core.TypeTypeResult + +public fun TvViewModel.openPodcast(podcast: Podcast) { + if (mutableState.value.isLoadingDetails) return + viewModelScope.launch { + mutableState.value = mutableState.value.copy( + selectedVideo = null, + selectedChannel = null, + selectedPlaylist = null, + selectedUserPlaylist = null, + selectedPodcast = null, + stream = null, + playback = null, + audioOnlyStream = null, + channelPodcasts = null, + channelPlaylists = null, + isLoadingDetails = true, + errorMessage = null, + ) + when (val result = client.catalog.podcastEpisodes(podcast.url)) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedPodcast = result.value, + isLoadingDetails = false, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isLoadingDetails = false, + errorMessage = result.error.toUserMessage(), + ) + } + } +} + +public fun TvViewModel.closePodcast() { + mutableState.value = mutableState.value.copy( + selectedPodcast = null, + isLoadingMoreCollection = false, + errorMessage = null, + ) +} + +public fun TvViewModel.loadMoreChannel() { + val current = mutableState.value.selectedChannel ?: return + val playlists = mutableState.value.channelPlaylists + val nextPage = current.nextPage + val playlistNextPage = playlists?.nextPage + if (nextPage == null && playlistNextPage == null) return + if (!beginCollectionPage()) return + viewModelScope.launch { + val channelDeferred = async { + nextPage?.let { client.catalog.channelPage(ChannelRequest(current.url, it)) } + } + val playlistsDeferred = async { + playlistNextPage?.let { client.catalog.channelPlaylists(ChannelRequest(current.url, it)) } + } + val channelResult = channelDeferred.await() + val playlistsResult = playlistsDeferred.await() + val error = listOfNotNull(channelResult, playlistsResult) + .mapNotNull { (it as? TypeTypeResult.Failure)?.error } + .firstOrNull() + if (error != null) { + finishCollectionPage(error.toUserMessage()) + } else { + val channelPage = (channelResult as? TypeTypeResult.Success)?.value + val playlistPage = (playlistsResult as? TypeTypeResult.Success)?.value + mutableState.value = mutableState.value.copy( + selectedChannel = channelPage?.let { + current.copy( + videos = (current.videos + it.videos).distinctBy { video -> video.id.value }, + nextPage = it.nextPage, + ) + } ?: current, + channelPlaylists = playlistPage?.let { + playlists?.copy( + playlists = (playlists.playlists + it.playlists).distinctBy { item -> item.id }, + nextPage = it.nextPage, + ) + } ?: playlists, + isLoadingMoreCollection = false, + errorMessage = null, + ) + } + } +} + +public fun TvViewModel.loadMorePlaylist() { + val current = mutableState.value.selectedPlaylist ?: return + val nextPage = current.nextPage ?: return + if (!beginCollectionPage()) return + viewModelScope.launch { + when (val result = client.catalog.playlist(PlaylistRequest(current.playlist.url, nextPage))) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedPlaylist = current.copy( + videos = (current.videos + result.value.videos).distinctBy { it.id.value }, + nextPage = result.value.nextPage, + ), + isLoadingMoreCollection = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishCollectionPage(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.loadMorePodcast() { + val current = mutableState.value.selectedPodcast ?: return + val nextPage = current.nextPage ?: return + if (!beginCollectionPage()) return + viewModelScope.launch { + when (val result = client.catalog.podcastEpisodes(current.podcast.url, nextPage)) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedPodcast = current.copy( + episodes = (current.episodes + result.value.episodes).distinctBy { it.id.value }, + nextPage = result.value.nextPage, + ), + isLoadingMoreCollection = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishCollectionPage(result.error.toUserMessage()) + } + } +} + +private fun TvViewModel.beginCollectionPage(): Boolean { + if (mutableState.value.isLoadingMoreCollection) return false + mutableState.value = mutableState.value.copy(isLoadingMoreCollection = true, errorMessage = null) + return true +} + +private fun TvViewModel.finishCollectionPage(error: String) { + mutableState.value = mutableState.value.copy( + isLoadingMoreCollection = false, + errorMessage = error, + ) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvCollections.kt b/tv/src/main/java/video/typetype/tv/data/TvCollections.kt new file mode 100644 index 00000000..9d9539ef --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvCollections.kt @@ -0,0 +1,233 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import video.typetype.sdk.core.Channel +import video.typetype.sdk.core.ChannelRequest +import video.typetype.sdk.core.Playlist +import video.typetype.sdk.core.PlaylistRequest +import video.typetype.sdk.core.PlaybackOpenRequest +import video.typetype.sdk.core.SavedPlaylist +import video.typetype.sdk.core.ServiceId +import video.typetype.sdk.core.TypeTypeResult +import video.typetype.sdk.core.UserPlaylist + +public fun TvViewModel.playNext() { + val next = mutableState.value.stream?.relatedStreams?.firstOrNull() + if (next == null) { + closePlayback() + return + } + playQueuedVideo(next) +} + +public fun TvViewModel.playQueuedVideo(next: video.typetype.sdk.core.Video) { + val navigationRelated = mutableState.value.stream?.relatedStreams.orEmpty() + .filterNot { it.id == next.id } + viewModelScope.launch { + mutableState.value = mutableState.value.copy(isAdvancingPlayback = true, errorMessage = null) + val service = next.serviceId + when (val result = loadStreamDetails(next.url, service)) { + is TypeTypeResult.Success -> { + val stream = result.value.withNavigationRelated(navigationRelated) + val supportedVideoItags = stream.videoOnlyStreams + .filter(isVideoSupported).mapTo(mutableSetOf()) { it.itag } + val standardSession = stream.standardPlaybackSession(service) + if (standardSession != null) { + mutableState.value = mutableState.value.copy( + selectedVideo = next, + selectedService = service, + stream = stream, + supportedVideoItags = supportedVideoItags, + playback = standardSession, + audioOnlyStream = null, + selectedVideoItag = null, + selectedAudioItag = null, + selectedAudioTrackId = null, + selectedSubtitleLanguage = null, + selectedSubtitleAuto = false, + selectedSubtitleName = null, + isAdvancingPlayback = false, + errorMessage = null, + ) + return@launch + } + if (service != ServiceId.YOUTUBE) { + mutableState.value = mutableState.value.copy( + isAdvancingPlayback = false, + errorMessage = "The server did not return a playable manifest for this service", + ) + return@launch + } + val tracks = stream.selectTvPlaybackTracks(isVideoSupported) + if (tracks == null) { + mutableState.value = mutableState.value.copy( + isAdvancingPlayback = false, + errorMessage = "The TypeType server did not return a playable SABR audio/video pair", + ) + return@launch + } + when (val opened = client.playback.open( + PlaybackOpenRequest( + videoUrl = next.url, + videoItag = tracks.video.itag, + audioItag = tracks.audio.itag, + audioTrackId = tracks.audio.audioTrackId, + startTimeMilliseconds = stream.startPositionMilliseconds, + isLive = stream.isLive, + ), + )) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedVideo = next, + selectedService = service, + stream = stream, + supportedVideoItags = supportedVideoItags, + playback = opened.value, + audioOnlyStream = null, + selectedVideoItag = tracks.video.itag, + selectedAudioItag = tracks.audio.itag, + selectedAudioTrackId = tracks.audio.audioTrackId, + selectedSubtitleLanguage = null, + selectedSubtitleAuto = false, + selectedSubtitleName = null, + isAdvancingPlayback = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isAdvancingPlayback = false, + errorMessage = "The next playback session could not start. ${opened.error.toUserMessage()}", + ) + } + } + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isAdvancingPlayback = false, + errorMessage = "The next video could not be loaded. ${result.error.toUserMessage()}", + ) + } + } +} + +public fun TvViewModel.openChannel(channel: Channel) { + viewModelScope.launch { + mutableState.value = mutableState.value.copy( + selectedVideo = null, + selectedPlaylist = null, + selectedUserPlaylist = null, + selectedPodcast = null, + selectedChannel = channel, + stream = null, + playback = null, + audioOnlyStream = null, + channelPodcasts = null, + channelPlaylists = null, + isLoadingDetails = true, + errorMessage = null, + ) + val channelDeferred = async { client.catalog.channel(ChannelRequest(channel.url)) } + val podcastsDeferred = async { client.catalog.podcasts(channel.url) } + val playlistsDeferred = async { client.catalog.channelPlaylists(ChannelRequest(channel.url)) } + val channelResult = channelDeferred.await() + val podcastsResult = podcastsDeferred.await() + val playlistsResult = playlistsDeferred.await() + val error = listOf(channelResult, podcastsResult, playlistsResult) + .mapNotNull { (it as? TypeTypeResult.Failure)?.error } + .firstOrNull() + mutableState.value = mutableState.value.copy( + selectedChannel = (channelResult as? TypeTypeResult.Success)?.value ?: channel, + channelPodcasts = (podcastsResult as? TypeTypeResult.Success)?.value, + channelPlaylists = (playlistsResult as? TypeTypeResult.Success)?.value, + isLoadingDetails = false, + errorMessage = if (channelResult is TypeTypeResult.Failure) { + "Channel details are temporarily unavailable" + } else { + error?.toUserMessage() + }, + ) + } +} + +public fun TvViewModel.openPlaylist(playlist: Playlist) { + viewModelScope.launch { + mutableState.value = mutableState.value.copy( + selectedVideo = null, + selectedChannel = null, + selectedUserPlaylist = null, + selectedPodcast = null, + selectedPlaylist = null, + stream = null, + playback = null, + audioOnlyStream = null, + channelPodcasts = null, + channelPlaylists = null, + isLoadingDetails = true, + errorMessage = null, + ) + when (val result = client.catalog.playlist(PlaylistRequest(playlist.url))) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedPlaylist = result.value, + isLoadingDetails = false, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isLoadingDetails = false, + errorMessage = result.error.toUserMessage(), + ) + } + } +} + +public fun TvViewModel.openUserPlaylist(playlist: UserPlaylist) { + viewModelScope.launch { + mutableState.value = mutableState.value.copy( + selectedVideo = null, + selectedChannel = null, + selectedPlaylist = null, + selectedPodcast = null, + selectedUserPlaylist = playlist, + stream = null, + playback = null, + audioOnlyStream = null, + channelPodcasts = null, + channelPlaylists = null, + isLoadingDetails = true, + errorMessage = null, + ) + when (val result = client.library.playlist(playlist.id)) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + selectedUserPlaylist = result.value, + isLoadingDetails = false, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isLoadingDetails = false, + errorMessage = result.error.toUserMessage(), + ) + } + } +} + +public fun TvViewModel.openSavedPlaylist(playlist: SavedPlaylist) { + openPlaylist( + Playlist( + id = playlist.publicPlaylistId, + title = playlist.title, + url = playlist.url, + thumbnailUrl = playlist.thumbnailUrl, + uploaderName = playlist.uploaderName, + streamCount = playlist.streamCount, + playlistType = playlist.playlistType, + ), + ) +} + +public fun TvViewModel.closeCollection() { + mutableState.value = mutableState.value.copy( + selectedChannel = null, + selectedPlaylist = null, + selectedUserPlaylist = null, + channelPodcasts = null, + channelPlaylists = null, + selectedPodcast = null, + isLoadingMoreCollection = false, + errorMessage = null, + ) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvComments.kt b/tv/src/main/java/video/typetype/tv/data/TvComments.kt new file mode 100644 index 00000000..36bb5ef8 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvComments.kt @@ -0,0 +1,80 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import video.typetype.sdk.core.Comment +import video.typetype.sdk.core.TypeTypeResult + +internal fun TvViewModel.loadInitialComments(videoUrl: String) { + viewModelScope.launch { + mutableState.value = mutableState.value.copy(isLoadingComments = true) + when (val result = client.catalog.comments(videoUrl)) { + is TypeTypeResult.Success -> { + if (mutableState.value.selectedVideo?.url != videoUrl) return@launch + mutableState.value = mutableState.value.copy( + comments = result.value.comments.usableComments(), + commentsNextPage = result.value.nextPage, + commentsDisabled = result.value.commentsDisabled, + isLoadingComments = false, + ) + } + is TypeTypeResult.Failure -> { + if (mutableState.value.selectedVideo?.url != videoUrl) return@launch + mutableState.value = mutableState.value.copy(isLoadingComments = false) + } + } + } +} + +public fun TvViewModel.loadMoreComments() { + val videoUrl = mutableState.value.selectedVideo?.url ?: return + val nextPage = mutableState.value.commentsNextPage ?: return + if (mutableState.value.isLoadingMoreComments) return + viewModelScope.launch { + mutableState.value = mutableState.value.copy(isLoadingMoreComments = true) + when (val result = client.catalog.comments(videoUrl, nextPage)) { + is TypeTypeResult.Success -> { + if (mutableState.value.selectedVideo?.url != videoUrl) return@launch + mutableState.value = mutableState.value.copy( + comments = (mutableState.value.comments + result.value.comments.usableComments()) + .distinctBy(Comment::id), + commentsNextPage = result.value.nextPage, + isLoadingMoreComments = false, + ) + } + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + isLoadingMoreComments = false, + errorMessage = result.error.toUserMessage(), + ) + } + } +} + +public fun TvViewModel.loadCommentReplies(comment: Comment) { + val videoUrl = mutableState.value.selectedVideo?.url ?: return + val repliesPage = comment.repliesPage ?: return + if (comment.id in mutableState.value.loadingCommentReplies) return + if (mutableState.value.commentReplies[comment.id] != null) return + viewModelScope.launch { + mutableState.value = mutableState.value.copy( + loadingCommentReplies = mutableState.value.loadingCommentReplies + comment.id, + ) + when (val result = client.catalog.commentReplies(videoUrl, repliesPage)) { + is TypeTypeResult.Success -> { + if (mutableState.value.selectedVideo?.url != videoUrl) return@launch + mutableState.value = mutableState.value.copy( + commentReplies = mutableState.value.commentReplies + + (comment.id to result.value.comments.usableComments()), + loadingCommentReplies = mutableState.value.loadingCommentReplies - comment.id, + ) + } + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + loadingCommentReplies = mutableState.value.loadingCommentReplies - comment.id, + errorMessage = result.error.toUserMessage(), + ) + } + } +} + +private fun List.usableComments(): List = + filter { it.author.isNotBlank() && it.text.isNotBlank() } diff --git a/tv/src/main/java/video/typetype/tv/data/TvDownloadOptions.kt b/tv/src/main/java/video/typetype/tv/data/TvDownloadOptions.kt new file mode 100644 index 00000000..549cb5ac --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvDownloadOptions.kt @@ -0,0 +1,136 @@ +package video.typetype.tv.data + +import video.typetype.sdk.core.CreateDownloadJobRequest +import video.typetype.sdk.core.DownloadMode +import video.typetype.sdk.core.DownloadOptions +import video.typetype.sdk.core.StreamAudio +import video.typetype.sdk.core.StreamDetails +import video.typetype.sdk.core.StreamVideo + +public enum class TvDownloadKind { + VIDEO, + AUDIO, +} + +public data class TvDownloadOption( + val id: String, + val kind: TvDownloadKind, + val label: String, + val detail: String, + val size: String, + val recommended: Boolean, + val request: CreateDownloadJobRequest, +) + +internal fun buildTvDownloadOptions(stream: StreamDetails, videoUrl: String): List { + val preferredAudio = stream.audioStreams.preferredDownloadAudio() + val videos = stream.videoOnlyStreams.ifEmpty { stream.videoStreams } + .distinctBy(StreamVideo::itag) + .sortedWith(compareByDescending { it.height }.thenByDescending { it.frameRate }) + .map { video -> video.toDownloadOption(videoUrl, preferredAudio, recommended = false) } + .toMutableList() + videos.recommendedVideoIndex()?.let { index -> videos[index] = videos[index].copy(recommended = true) } + val audios = stream.audioStreams.distinctBy(StreamAudio::itag) + .sortedByDescending { it.bitrate ?: 0L } + .map { audio -> audio.toDownloadOption(videoUrl, recommended = audio.itag == preferredAudio?.itag) } + return videos + audios +} + +private fun StreamVideo.toDownloadOption( + videoUrl: String, + audio: StreamAudio?, + recommended: Boolean, +): TvDownloadOption { + val container = mediaContainer(mimeType, format, "mp4") + val quality = when { + height >= 1080 -> "best" + height >= 720 -> "balanced" + else -> "small" + } + val fpsLabel = frameRate.takeIf { it > 30 }?.let { " ${it}fps" }.orEmpty() + val estimatedBytes = contentLength.coerceAtLeast(0L) + (audio?.contentLength ?: 0L).coerceAtLeast(0L) + return TvDownloadOption( + id = "video-$itag", + kind = TvDownloadKind.VIDEO, + label = "${resolution.ifBlank { "${height}p" }}$fpsLabel", + detail = listOfNotNull(codec, container.uppercase(), "itag $itag").joinToString(" · "), + size = formatBytes(estimatedBytes), + recommended = recommended, + request = CreateDownloadJobRequest( + videoUrl, + DownloadOptions( + mode = DownloadMode.Video, + quality = quality, + format = container, + videoItag = itag.toString(), + audioItag = audio?.itag?.toString(), + height = height.takeIf { it > 0 }, + fps = frameRate.takeIf { it > 0 }, + videoCodec = codec, + audioCodec = audio?.codec, + allowQualityFallback = false, + ), + ), + ) +} + +private fun StreamAudio.toDownloadOption(videoUrl: String, recommended: Boolean): TvDownloadOption { + val container = mediaContainer(mimeType, format, "m4a") + val bitrateValue = bitrate?.coerceAtMost(Int.MAX_VALUE.toLong())?.toInt() + val displayedBitrate = bitrateValue?.let { if (it >= 10_000) it / 1_000 else it } + val quality = when { + (displayedBitrate ?: 0) >= 192 -> "best" + (displayedBitrate ?: 0) >= 128 -> "balanced" + else -> "small" + } + val language = audioTrackName ?: audioLocale ?: quality + return TvDownloadOption( + id = "audio-$itag", + kind = TvDownloadKind.AUDIO, + label = displayedBitrate?.let { "$it kbps" } ?: "Audio", + detail = listOfNotNull(language, codec, container.uppercase(), "itag $itag").joinToString(" · "), + size = formatBytes(contentLength), + recommended = recommended, + request = CreateDownloadJobRequest( + videoUrl, + DownloadOptions( + mode = DownloadMode.Audio, + quality = quality, + format = container, + audioItag = itag.toString(), + audioCodec = codec, + bitrate = bitrateValue, + allowQualityFallback = false, + ), + ), + ) +} + +private fun List.preferredDownloadAudio(): StreamAudio? = + firstOrNull(StreamAudio::isOriginal) ?: maxByOrNull { it.bitrate ?: 0L } + +private fun List.recommendedVideoIndex(): Int? { + if (isEmpty()) return null + val fullHd = indexOfFirst { it.request.options.height == 1080 } + if (fullHd >= 0) return fullHd + val hd = indexOfFirst { it.request.options.height == 720 } + return if (hd >= 0) hd else lastIndex +} + +private fun mediaContainer(mimeType: String, format: String, fallback: String): String = + format.trim().lowercase().takeIf(String::isNotBlank) + ?: mimeType.substringBefore(';').substringAfter('/', "").lowercase().takeIf(String::isNotBlank) + ?: fallback + +private fun formatBytes(bytes: Long): String { + if (bytes <= 0L) return "Size unavailable" + val units = arrayOf("B", "KB", "MB", "GB") + var value = bytes.toDouble() + var unit = 0 + while (value >= 1024.0 && unit < units.lastIndex) { + value /= 1024.0 + unit += 1 + } + val decimals = if (value >= 100) 0 else if (value >= 10) 1 else 2 + return "%.${decimals}f %s".format(value, units[unit]) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvDownloadStateStore.kt b/tv/src/main/java/video/typetype/tv/data/TvDownloadStateStore.kt new file mode 100644 index 00000000..67851384 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvDownloadStateStore.kt @@ -0,0 +1,27 @@ +package video.typetype.tv.data + +import android.content.Context +import video.typetype.sdk.core.SessionSnapshot + +public class TvDownloadStateStore(context: Context) { + private val preferences = context.applicationContext.getSharedPreferences( + "typetype_tv_download_jobs", + Context.MODE_PRIVATE, + ) + + public fun read(session: SessionSnapshot): String? = preferences.getString(session.storageKey(), null) + + public fun write(session: SessionSnapshot, jobId: String) { + preferences.edit().putString(session.storageKey(), jobId).apply() + } + + public fun clear(session: SessionSnapshot) { + preferences.edit().remove(session.storageKey()).apply() + } +} + +private fun SessionSnapshot.storageKey(): String = buildString { + append(instanceId.value) + append('|') + append(accountId?.value ?: if (isGuest) "guest" else "default") +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvDownloads.kt b/tv/src/main/java/video/typetype/tv/data/TvDownloads.kt new file mode 100644 index 00000000..0bc256e4 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvDownloads.kt @@ -0,0 +1,184 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import video.typetype.sdk.core.DownloadJob +import video.typetype.sdk.core.DownloadJobStatus +import video.typetype.sdk.core.SessionSnapshot +import video.typetype.sdk.core.TypeTypeError +import video.typetype.sdk.core.TypeTypeResult + +public fun TvViewModel.startDownload(option: TvDownloadOption) { + val current = mutableState.value.downloadJob + if (current?.status == DownloadJobStatus.Queued || current?.status == DownloadJobStatus.Running || + mutableState.value.isSavingDownload + ) return + downloadTask?.cancel() + downloadTask = viewModelScope.launch { + val session = client.sessions.current() + mutableState.value = mutableState.value.copy( + downloadJob = null, + downloadMessage = null, + downloadError = null, + isSavingDownload = false, + ) + when (val created = client.downloader.create(option.request)) { + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + downloadError = created.error.toUserMessage(), + ) + is TypeTypeResult.Success -> { + downloadSession = session + if (session != null) downloadStateStore.write(session, created.value.id) + monitorDownload(created.value) + } + } + } +} + +internal fun TvViewModel.resumePendingDownload(session: SessionSnapshot) { + val id = downloadStateStore.read(session) ?: return + downloadTask?.cancel() + downloadSession = session + downloadTask = viewModelScope.launch { + var failures = 0 + while (isActive) { + when (val result = client.downloader.job(id)) { + is TypeTypeResult.Success -> { + monitorDownload(result.value) + return@launch + } + is TypeTypeResult.Failure -> { + if (result.error.isMissingDownload()) { + downloadStateStore.clear(session) + downloadSession = null + return@launch + } + failures++ + if (!result.error.isRetryableDownloadFailure() || failures >= MAX_DOWNLOAD_RETRIES) { + mutableState.value = mutableState.value.copy(downloadError = result.error.toUserMessage()) + return@launch + } + mutableState.value = mutableState.value.copy( + downloadError = "Connection interrupted. TypeType is trying again.", + ) + delay(downloadRetryDelay(failures)) + } + } + } + } +} + +public fun TvViewModel.cancelDownload() { + val job = mutableState.value.downloadJob ?: return + if (job.status != DownloadJobStatus.Queued && job.status != DownloadJobStatus.Running) return + downloadTask?.cancel() + downloadTask = viewModelScope.launch { + when (val result = client.downloader.cancel(job.id)) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + downloadJob = result.value, + downloadError = null, + ) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + downloadError = result.error.toUserMessage(), + ) + } + } +} + +public fun TvViewModel.clearDownload() { + val job = mutableState.value.downloadJob + if (mutableState.value.isSavingDownload || job?.status == DownloadJobStatus.Queued || + job?.status == DownloadJobStatus.Running + ) return + mutableState.value = mutableState.value.copy( + downloadJob = null, + downloadMessage = null, + downloadError = null, + ) + downloadSession?.let(downloadStateStore::clear) + downloadSession = null + if (job != null) viewModelScope.launch { client.downloader.delete(job.id) } +} + +public fun TvViewModel.retryDownloadArtifact() { + val job = mutableState.value.downloadJob?.takeIf { it.status == DownloadJobStatus.Done } ?: return + if (mutableState.value.isSavingDownload) return + downloadTask?.cancel() + downloadTask = viewModelScope.launch { saveArtifact(job) } +} + +private suspend fun TvViewModel.monitorDownload(initial: DownloadJob) { + var job = initial + var failures = 0 + mutableState.value = mutableState.value.copy(downloadJob = job, downloadError = null) + while (kotlin.coroutines.coroutineContext.isActive && + (job.status == DownloadJobStatus.Queued || job.status == DownloadJobStatus.Running) + ) { + delay(DOWNLOAD_POLL_MILLISECONDS) + when (val result = client.downloader.job(job.id)) { + is TypeTypeResult.Failure -> { + failures++ + if (!result.error.isRetryableDownloadFailure() || failures >= MAX_DOWNLOAD_RETRIES) { + mutableState.value = mutableState.value.copy(downloadError = result.error.toUserMessage()) + return + } + mutableState.value = mutableState.value.copy( + downloadError = "Connection interrupted. TypeType is trying again.", + ) + delay(downloadRetryDelay(failures)) + } + is TypeTypeResult.Success -> { + failures = 0 + job = result.value + mutableState.value = mutableState.value.copy(downloadJob = job, downloadError = job.error) + } + } + } + when (job.status) { + DownloadJobStatus.Done -> saveArtifact(job) + DownloadJobStatus.Failed -> mutableState.value = mutableState.value.copy( + downloadError = job.error ?: "The server could not complete this download", + ) + else -> Unit + } +} + +private suspend fun TvViewModel.saveArtifact(job: DownloadJob) { + mutableState.value = mutableState.value.copy(isSavingDownload = true, downloadError = null) + artifactStore.save(client.downloader, job).fold( + onSuccess = { saved -> + mutableState.value = mutableState.value.copy( + isSavingDownload = false, + downloadMessage = "${saved.fileName} saved in ${saved.location}", + downloadError = null, + ) + client.downloader.delete(job.id) + downloadSession?.let(downloadStateStore::clear) + downloadSession = null + }, + onFailure = { failure -> + mutableState.value = mutableState.value.copy( + isSavingDownload = false, + downloadError = failure.message ?: "Android could not save the downloaded file", + ) + }, + ) +} + +private fun TypeTypeError.isMissingDownload(): Boolean = this is TypeTypeError.Http && status == 404 + +private fun TypeTypeError.isRetryableDownloadFailure(): Boolean = when (this) { + is TypeTypeError.Network -> true + is TypeTypeError.Http -> status == 408 || status == 425 || status == 429 || status in 500..599 + else -> false +} + +private fun downloadRetryDelay(failures: Int): Long = + (DOWNLOAD_RETRY_BASE_MILLISECONDS * (1L shl (failures - 1))).coerceAtMost(DOWNLOAD_RETRY_MAX_MILLISECONDS) + +private const val DOWNLOAD_POLL_MILLISECONDS = 1_500L +private const val DOWNLOAD_RETRY_BASE_MILLISECONDS = 1_000L +private const val DOWNLOAD_RETRY_MAX_MILLISECONDS = 8_000L +private const val MAX_DOWNLOAD_RETRIES = 5 diff --git a/tv/src/main/java/video/typetype/tv/data/TvErrors.kt b/tv/src/main/java/video/typetype/tv/data/TvErrors.kt new file mode 100644 index 00000000..b4b20398 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvErrors.kt @@ -0,0 +1,36 @@ +package video.typetype.tv.data + +import video.typetype.sdk.core.TypeTypeError + +internal fun TypeTypeError.toUserMessage(): String = when (this) { + is TypeTypeError.Http -> httpSummary(status, message).withDiagnostic(status, code, requestId) + is TypeTypeError.Network -> "TypeType could not be reached. Check your connection and try again." + .withRequestId(requestId) + is TypeTypeError.Serialization -> "TypeType returned a response this app could not read." + .withRequestId(requestId) + is TypeTypeError.InvalidRequest -> message.withRequestId(requestId) + is TypeTypeError.Authentication -> (message ?: "Sign in again to continue.") + .withDiagnostic(status, code, requestId) +} + +private fun httpSummary(status: Int, serverMessage: String?): String = when (status) { + 400 -> serverMessage?.takeIf(String::isNotBlank) ?: "This request could not be completed." + 401, 403 -> "Sign in again to continue." + 404 -> "This content is no longer available." + 409 -> serverMessage?.takeIf(String::isNotBlank) ?: "This action conflicts with a newer change." + 429 -> "TypeType is receiving too many requests. Try again in a moment." + in 500..599 -> "TypeType is temporarily unavailable. Try again in a moment." + else -> serverMessage?.takeIf(String::isNotBlank) ?: "TypeType could not complete this request." +} + +private fun String.withDiagnostic(status: Int, code: String?, requestId: String?): String = buildString { + append(this@withDiagnostic) + append("\nError ").append(status) + code?.let { append(" · ").append(it) } + requestId?.let { append(" · request ").append(it) } +} + +private fun String.withRequestId(requestId: String?): String = buildString { + append(this@withRequestId) + requestId?.let { append("\nRequest ").append(it) } +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvHomeData.kt b/tv/src/main/java/video/typetype/tv/data/TvHomeData.kt new file mode 100644 index 00000000..c5101209 --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvHomeData.kt @@ -0,0 +1,96 @@ +package video.typetype.tv.data + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import video.typetype.sdk.core.SearchRequest +import video.typetype.sdk.core.TypeTypeError +import video.typetype.sdk.core.TypeTypeResult + +internal suspend fun TvViewModel.loadAuthenticatedContent(): Unit = coroutineScope { + loadUserSettings() + launch { loadHomeContent() } + launch { loadLibraryContent(showLoading = false) } + launch { loadProfile() } +} + +internal suspend fun TvViewModel.loadProfile() { + when (val result = client.profile.profile()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy(profile = result.value) + is TypeTypeResult.Failure -> mutableState.value = mutableState.value.copy( + errorMessage = result.error.toUserMessage(), + ) + } +} + +internal suspend fun TvViewModel.loadHomeContent(): Unit = coroutineScope { + val snapshot = mutableState.value + val service = snapshot.selectedService + val metadataDeferred = async { + if (snapshot.metadata == null) client.instance.metadata() else null + } + val settings = snapshot.settings + val homeDeferred = async { + if (settings.hideHomeRecommendations) null else client.recommendations.home(service, limit = 24) + } + val trendingDeferred = async { client.catalog.trending(service) } + val bunnyDeferred = async { client.catalog.search(SearchRequest("Big Buck Bunny", service)) } + val shortsDeferred = async { + if (settings.hideShorts) null else client.recommendations.shorts(service, limit = 20) + } + val authenticated = snapshot.authStatus == TvAuthStatus.AUTHENTICATED + val subscriptionsDeferred = async { if (authenticated) client.subscriptions.groupMemberships() else null } + val groupsDeferred = async { if (authenticated) client.subscriptions.groups() else null } + val feedDeferred = async { + if (authenticated) client.subscriptions.feed(groupId = snapshot.selectedSubscriptionGroupId, limit = 24) else null + } + + val metadataResult = metadataDeferred.await() + val homeResult = homeDeferred.await() + val trendingResult = trendingDeferred.await() + val bunnyResult = bunnyDeferred.await() + val shortsResult = shortsDeferred.await() + val subscriptionsResult = subscriptionsDeferred.await() + val groupsResult = groupsDeferred.await() + val feedResult = feedDeferred.await() + val errors = listOfNotNull( + metadataResult, homeResult, trendingResult, bunnyResult, shortsResult, + subscriptionsResult, groupsResult, feedResult, + ).mapNotNull { (it as? TypeTypeResult.Failure)?.error } + if (errors.firstOrNull() is TypeTypeError.Authentication) { + client.sessions.clear() + mutableState.value = TvAppState( + authStatus = TvAuthStatus.SIGNED_OUT, + metadata = mutableState.value.metadata, + isLoading = false, + errorMessage = errors.first().toUserMessage(), + ) + return@coroutineScope + } + val current = mutableState.value + val bunnyPage = bunnyResult as? TypeTypeResult.Success + val bunny = bunnyPage?.value?.videos + ?.firstOrNull { it.title.contains("big buck bunny", ignoreCase = true) } + mutableState.value = current.copy( + metadata = (metadataResult as? TypeTypeResult.Success)?.value ?: current.metadata, + home = (homeResult as? TypeTypeResult.Success)?.value?.items?.visibleWith(settings) + ?: if (settings.hideHomeRecommendations) emptyList() else current.home, + trending = (trendingResult as? TypeTypeResult.Success)?.value?.visibleWith(settings) ?: current.trending, + bigBuckBunny = if (bunnyPage != null) bunny else current.bigBuckBunny, + shorts = (shortsResult as? TypeTypeResult.Success)?.value?.items?.visibleWith(settings) + ?: if (settings.hideShorts) emptyList() else current.shorts, + subscriptions = (subscriptionsResult as? TypeTypeResult.Success)?.value ?: current.subscriptions, + subscriptionGroups = (groupsResult as? TypeTypeResult.Success)?.value ?: current.subscriptionGroups, + subscriptionFeed = (feedResult as? TypeTypeResult.Success)?.value?.items?.visibleWith(settings) + ?: current.subscriptionFeed, + isLoading = false, + errorMessage = errors.firstOrNull()?.toUserMessage(), + ) +} + +private fun List.visibleWith( + settings: video.typetype.sdk.core.UserSettings, +): List = filter { video -> + (!settings.hideSubscriptionLiveStreams || !video.isLive) && + (!settings.hideMembersOnlyContent || !video.requiresMembership) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvLibraryActions.kt b/tv/src/main/java/video/typetype/tv/data/TvLibraryActions.kt new file mode 100644 index 00000000..42a5f92e --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvLibraryActions.kt @@ -0,0 +1,236 @@ +package video.typetype.tv.data + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import video.typetype.sdk.core.Channel +import video.typetype.sdk.core.PublicPlaylist +import video.typetype.sdk.core.TypeTypeResult +import video.typetype.sdk.core.Video +import video.typetype.sdk.core.UserPlaylist +import video.typetype.sdk.core.WatchLaterItem + +public fun TvViewModel.toggleFavorite(video: Video) { + if (!beginAuthenticatedAction()) return + val remove = mutableState.value.favorites.any { it.video.url == video.url } + viewModelScope.launch { + val mutation = if (remove) { + client.library.removeFavorite(video.url) + } else { + client.library.addFavorite(video.url) + } + when (mutation) { + is TypeTypeResult.Success -> refreshFavorites() + is TypeTypeResult.Failure -> finishAction(mutation.error.toUserMessage()) + } + } +} + +public fun TvViewModel.toggleWatchLater(video: Video) { + if (!beginAuthenticatedAction()) return + val remove = mutableState.value.watchLater.any { it.video.url == video.url } + viewModelScope.launch { + val mutation = if (remove) { + client.library.removeWatchLater(video.url) + } else { + client.library.addWatchLater(WatchLaterItem(video, System.currentTimeMillis())) + } + when (mutation) { + is TypeTypeResult.Success -> refreshWatchLater() + is TypeTypeResult.Failure -> finishAction(mutation.error.toUserMessage()) + } + } +} + +public fun TvViewModel.toggleSubscription(channel: Channel) { + if (!beginAuthenticatedAction()) return + val remove = mutableState.value.subscriptions.any { it.channelUrl == channel.url } + viewModelScope.launch { + val mutation = if (remove) { + client.subscriptions.unsubscribe(channel.url) + } else { + client.subscriptions.subscribe(channel.url, channel.name, channel.avatarUrl) + } + when (mutation) { + is TypeTypeResult.Success -> refreshSubscriptions() + is TypeTypeResult.Failure -> finishAction(mutation.error.toUserMessage()) + } + } +} + +public fun TvViewModel.toggleSavedPlaylist(playlist: PublicPlaylist) { + if (!beginAuthenticatedAction()) return + val saved = mutableState.value.savedPlaylists.firstOrNull { + it.publicPlaylistId == playlist.playlist.id || it.url == playlist.playlist.url + } + viewModelScope.launch { + val mutation = if (saved == null) { + client.library.savePlaylist(playlist.playlist.url) + } else { + client.library.deleteSavedPlaylist(saved.id) + } + when (mutation) { + is TypeTypeResult.Success -> refreshSavedPlaylists() + is TypeTypeResult.Failure -> finishAction(mutation.error.toUserMessage()) + } + } +} + +public fun TvViewModel.clearHistory() { + if (!beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.clearHistory()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + history = emptyList(), + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.togglePlaylistVideo(playlist: UserPlaylist, video: Video) { + if (!beginAuthenticatedAction()) return + val remove = playlist.videos.any { it.url == video.url } + viewModelScope.launch { + val mutation = if (remove) { + client.library.removePlaylistVideo(playlist.id, video.url) + } else { + client.library.addPlaylistVideo(playlist.id, video) + } + when (mutation) { + is TypeTypeResult.Success -> refreshPlaylists() + is TypeTypeResult.Failure -> finishAction(mutation.error.toUserMessage()) + } + } +} + +public fun TvViewModel.createPlaylist(name: String) { + if (name.isBlank() || !beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.createPlaylist(name.trim())) { + is TypeTypeResult.Success -> refreshPlaylists() + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.renamePlaylist(playlist: UserPlaylist, name: String) { + if (name.isBlank() || !beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.updatePlaylist(playlist.id, name.trim(), playlist.description)) { + is TypeTypeResult.Success -> refreshPlaylists() + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.deletePlaylist(playlist: UserPlaylist) { + if (!beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.deletePlaylist(playlist.id)) { + is TypeTypeResult.Success -> { + mutableState.value = mutableState.value.copy(selectedUserPlaylist = null) + refreshPlaylists() + } + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.removePlaylistVideo(playlist: UserPlaylist, video: Video) { + if (!beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.removePlaylistVideo(playlist.id, video.url)) { + is TypeTypeResult.Success -> refreshPlaylists() + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +public fun TvViewModel.movePlaylistVideo(playlist: UserPlaylist, video: Video, offset: Int) { + val reordered = reorderedVideoIds( + playlist.videos.map { it.id.value }, + video.id.value, + offset, + ) ?: return + if (!beginAuthenticatedAction()) return + viewModelScope.launch { + when (val result = client.library.reorderPlaylist(playlist.id, reordered)) { + is TypeTypeResult.Success -> refreshPlaylists() + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } + } +} + +internal fun TvViewModel.beginAuthenticatedAction(): Boolean { + if (mutableState.value.isActionInProgress) return false + if (mutableState.value.authStatus != TvAuthStatus.AUTHENTICATED) { + mutableState.value = mutableState.value.copy(errorMessage = "Sign in to change your library") + return false + } + mutableState.value = mutableState.value.copy(isActionInProgress = true, errorMessage = null) + return true +} + +private suspend fun TvViewModel.refreshFavorites() { + when (val result = client.library.favorites()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + favorites = result.value, + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } +} + +private suspend fun TvViewModel.refreshWatchLater() { + when (val result = client.library.watchLater()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + watchLater = result.value, + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } +} + +internal suspend fun TvViewModel.refreshSubscriptions() { + when (val result = client.subscriptions.list()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + subscriptions = result.value, + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } +} + +private suspend fun TvViewModel.refreshPlaylists() { + when (val result = client.library.playlists()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + playlists = result.value, + selectedUserPlaylist = mutableState.value.selectedUserPlaylist?.let { selected -> + result.value.firstOrNull { it.id == selected.id } + }, + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } +} + +private suspend fun TvViewModel.refreshSavedPlaylists() { + when (val result = client.library.savedPlaylists()) { + is TypeTypeResult.Success -> mutableState.value = mutableState.value.copy( + savedPlaylists = result.value, + isActionInProgress = false, + errorMessage = null, + ) + is TypeTypeResult.Failure -> finishAction(result.error.toUserMessage()) + } +} + +internal fun TvViewModel.finishAction(error: String) { + mutableState.value = mutableState.value.copy(isActionInProgress = false, errorMessage = error) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvLibraryData.kt b/tv/src/main/java/video/typetype/tv/data/TvLibraryData.kt new file mode 100644 index 00000000..b646a5cc --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvLibraryData.kt @@ -0,0 +1,31 @@ +package video.typetype.tv.data + +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import video.typetype.sdk.core.TypeTypeResult + +internal suspend fun TvViewModel.loadLibraryContent(showLoading: Boolean): Unit = coroutineScope { + if (showLoading) mutableState.value = mutableState.value.copy(isLoadingLibrary = true, errorMessage = null) + val historyDeferred = async { client.library.history() } + val watchLaterDeferred = async { client.library.watchLater() } + val favoritesDeferred = async { client.library.favorites() } + val playlistsDeferred = async { client.library.playlists() } + val savedPlaylistsDeferred = async { client.library.savedPlaylists() } + val history = historyDeferred.await() + val watchLater = watchLaterDeferred.await() + val favorites = favoritesDeferred.await() + val playlists = playlistsDeferred.await() + val savedPlaylists = savedPlaylistsDeferred.await() + val errors = listOf(history, watchLater, favorites, playlists, savedPlaylists) + .mapNotNull { (it as? TypeTypeResult.Failure)?.error } + val current = mutableState.value + mutableState.value = current.copy( + history = (history as? TypeTypeResult.Success)?.value ?: current.history, + watchLater = (watchLater as? TypeTypeResult.Success)?.value ?: current.watchLater, + favorites = (favorites as? TypeTypeResult.Success)?.value ?: current.favorites, + playlists = (playlists as? TypeTypeResult.Success)?.value ?: current.playlists, + savedPlaylists = (savedPlaylists as? TypeTypeResult.Success)?.value ?: current.savedPlaylists, + isLoadingLibrary = false, + errorMessage = errors.firstOrNull()?.toUserMessage() ?: current.errorMessage, + ) +} diff --git a/tv/src/main/java/video/typetype/tv/data/TvModels.kt b/tv/src/main/java/video/typetype/tv/data/TvModels.kt new file mode 100644 index 00000000..9edcb66c --- /dev/null +++ b/tv/src/main/java/video/typetype/tv/data/TvModels.kt @@ -0,0 +1,146 @@ +package video.typetype.tv.data + +import video.typetype.sdk.core.PlaybackSession +import video.typetype.sdk.core.SearchPage +import video.typetype.sdk.core.SearchFilters +import video.typetype.sdk.core.StreamDetails +import video.typetype.sdk.core.Video +import video.typetype.sdk.core.InstanceMetadata +import video.typetype.sdk.core.FavoriteItem +import video.typetype.sdk.core.HistoryItem +import video.typetype.sdk.core.UserPlaylist +import video.typetype.sdk.core.WatchLaterItem +import video.typetype.sdk.core.UserProfile +import video.typetype.sdk.core.Channel +import video.typetype.sdk.core.Comment +import video.typetype.sdk.core.PodcastPage +import video.typetype.sdk.core.PodcastEpisodesPage +import video.typetype.sdk.core.PublicPlaylist +import video.typetype.sdk.core.PlaylistPage +import video.typetype.sdk.core.SavedPlaylist +import video.typetype.sdk.core.Subscription +import video.typetype.sdk.core.SubscriptionGroup +import video.typetype.sdk.core.ServiceId +import video.typetype.sdk.core.AudioOnlyStream +import video.typetype.sdk.core.UserSettings +import video.typetype.sdk.core.DownloadJob + +public enum class TvDestination { + HOME, + SEARCH, + LIBRARY, + SETTINGS, +} + +public enum class TvAuthStatus { + CHECKING, + SIGNED_OUT, + AUTHENTICATED, + GUEST, +} + +public data class TvAppState( + val authStatus: TvAuthStatus = TvAuthStatus.CHECKING, + val profile: UserProfile? = null, + val settings: UserSettings = UserSettings(), + val destination: TvDestination = TvDestination.HOME, + val metadata: InstanceMetadata? = null, + val home: List