diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml new file mode 100644 index 00000000..524fe121 --- /dev/null +++ b/.github/workflows/android-beta.yml @@ -0,0 +1,199 @@ +name: Android Beta + +on: + push: + branches: + - beta + paths: + - 'app/**' + - 'player/**' + - 'baseline-profile/**' + - 'gradle/**' + - '*.gradle.kts' + - 'gradle.properties' + - 'settings.gradle.kts' + pull_request: + branches: + - beta + paths: + - 'app/**' + - 'player/**' + - 'baseline-profile/**' + - 'gradle/**' + - '*.gradle.kts' + - 'gradle.properties' + - 'settings.gradle.kts' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: android-beta + cancel-in-progress: false + +env: + CI: true + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + JAVA_TOOL_OPTIONS: -Djava.awt.headless=true + GRADLE_OPTS: -Dorg.gradle.workers.max=8 -Dorg.gradle.daemon.idletimeout=900000 + ANDROID_HOME: /opt/android-sdk + ANDROID_SDK_ROOT: /opt/android-sdk + JAVA_HOME: /usr/lib/jvm/java-26-openjdk + GRADLE_USER_HOME: /var/tmp/typetype-android-gradle-user-home + GITHUB_REPO: TypeType-Video/TypeType-Android + +jobs: + release: + runs-on: [self-hosted, Linux, X64, arko, android] + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Install Android SDK platform + run: | + platform="$ANDROID_HOME/platforms/android-37.0" + ( + flock 9 + if [ ! -f "$platform/android.jar" ]; then + rm -rf "$platform" + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + --sdk_root="$ANDROID_HOME" \ + "platforms;android-37.0" + fi + test -f "$platform/android.jar" + ) 9>"$ANDROID_HOME/.platform-install.lock" + + - name: Validate beta release version + env: + GH_TOKEN: ${{ github.token }} + run: | + version_name="$(sed -nE 's/^[[:space:]]*versionName = "([^"]+)"/\1/p' app/build.gradle.kts)" + version_code="$(sed -nE 's/^[[:space:]]*versionCode = ([0-9]+)/\1/p' app/build.gradle.kts)" + release_tag="v${version_name}" + + [[ "$version_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$ ]] + [[ "$version_code" =~ ^[1-9][0-9]*$ ]] + + latest_code="$( + gh api \ + -H 'Accept: application/vnd.github+json' \ + --paginate "repos/${GITHUB_REPO}/releases" \ + --jq '.[] | select(.draft == false and .prerelease == true) | .assets[]?.name | capture("^dev\\.typetype\\.android_(?[0-9]+)\\.apk$").code' \ + 2>/dev/null | sort -nu | tail -n 1 + )" + latest_code="${latest_code:-0}" + + if [ "$version_code" -le "$latest_code" ]; then + echo "versionCode $version_code must be greater than latest beta $latest_code" >&2 + exit 1 + fi + + if gh api "repos/${GITHUB_REPO}/git/ref/tags/${release_tag}" > /dev/null 2>&1; then + echo "Tag ${release_tag} already exists" >&2 + exit 1 + fi + + { + echo "RELEASE_VERSION=${version_name}" + echo "RELEASE_CODE=${version_code}" + echo "RELEASE_TAG=${release_tag}" + } >> "$GITHUB_ENV" + + - name: Prepare signing + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + test -n "$ANDROID_KEYSTORE_BASE64" + test -n "$ANDROID_KEYSTORE_PASSWORD" + test -n "$ANDROID_KEY_ALIAS" + test -n "$ANDROID_KEY_PASSWORD" + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore + { + printf 'storeFile=%s\n' "$PWD/release.keystore" + printf 'storePassword=%s\n' "$ANDROID_KEYSTORE_PASSWORD" + printf 'keyAlias=%s\n' "$ANDROID_KEY_ALIAS" + printf 'keyPassword=%s\n' "$ANDROID_KEY_PASSWORD" + } > keystore.properties + + - name: Verify and build beta release + timeout-minutes: 25 + run: | + ./gradlew --build-cache --stacktrace \ + :baseline-profile:assemble \ + :player:testDebugUnitTest \ + :app:testDebugUnitTest \ + :player:lintDebug \ + :app:lintDebug \ + :app:assembleDebug \ + :app:assembleRelease + + - name: Upload lint report + if: ${{ always() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: typetype-beta-lint-report + path: | + app/build/reports/lint-results-debug.html + player/build/reports/lint-results-debug.html + if-no-files-found: ignore + retention-days: 14 + + - name: Verify and prepare beta APK + run: | + mkdir -p artifacts + apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)" + test -n "$apk" + build_tools="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + test -n "$build_tools" + + "$build_tools/zipalign" -c -P 16 -v 4 "$apk" + "$build_tools/apksigner" verify --verbose --print-certs "$apk" + + apk_application_id="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest application-id "$apk")" + apk_version="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-name "$apk")" + apk_code="$("$ANDROID_HOME/cmdline-tools/latest/bin/apkanalyzer" manifest version-code "$apk")" + test "$apk_application_id" = "dev.typetype.android" + test "$apk_version" = "$RELEASE_VERSION" + test "$apk_code" = "$RELEASE_CODE" + + cp "$apk" "artifacts/dev.typetype.android_${RELEASE_CODE}.apk" + ( + cd artifacts + sha256sum "dev.typetype.android_${RELEASE_CODE}.apk" \ + > "dev.typetype.android_${RELEASE_CODE}.apk.sha256" + sha256sum -c "dev.typetype.android_${RELEASE_CODE}.apk.sha256" + ) + + - name: Upload beta APK + uses: actions/upload-artifact@v7.0.1 + with: + name: typetype-beta-apk + path: | + artifacts/*.apk + artifacts/*.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + - name: Publish GitHub prerelease + if: ${{ github.event_name != 'pull_request' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: TypeType Android ${{ env.RELEASE_VERSION }} + body: ${{ github.event.head_commit.message }} + prerelease: true + make_latest: false + generate_release_notes: false + target_commitish: beta + files: | + artifacts/*.apk + artifacts/*.sha256 + fail_on_unmatched_files: true 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/.gitignore b/.gitignore index ed9ab42b..d90082ce 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ google-services.json # Android Profiling *.hprof .DS_Store +__pycache__/ +*.py[cod] # Local documentation /docs/ 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 0c880621..b6e6f867 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ require Google Play Services. TypeType Android uses the TypeType Server selected during setup for extraction, playback sessions, recommendations, synchronization, and downloads. +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 | Welcome | Add an instance | Home | @@ -50,6 +54,19 @@ playback sessions, recommendations, synchronization, and downloads. sleep timer. - Download supported videos and import existing data. +## Android TV + +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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 486f325d..fe80e746 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 = 10700 - versionName = "1.7.0" + versionCode = 10820 + versionName = "1.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } @@ -140,6 +140,10 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.jsoup) + implementation(libs.unifiedpush.connector) { + exclude(group = "com.google.crypto.tink", module = "tink") + } implementation(libs.androidx.navigation.compose) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt new file mode 100644 index 00000000..d1958adc --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt @@ -0,0 +1,53 @@ +package dev.typetype.android.feature.player.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.domain.comments.Comment +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CommentBodyTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun longCommentCollapsesAndExpandsWithReadMore() { + val suffix = " more comment detail".repeat(60) + val comment = Comment( + id = "comment", + text = "Long comment starts here$suffix", + authorName = "Reporter", + authorAvatarUrl = "", + likeCount = 2, + textualLikeCount = "2", + publishedTime = "1 day ago", + isHeartedByUploader = false, + isPinned = false, + uploaderVerified = false, + replyCount = 0, + ) + + composeRule.setContent { + CommentBody( + comment = comment, + avatarSize = 36.dp, + onUrlClick = {}, + onTimestampClick = {}, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) + } + + composeRule.onNodeWithText("Read more").assertExists().performClick() + composeRule.onNodeWithText("Show less").assertExists().performClick() + composeRule.onNodeWithText("Read more").assertExists() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt new file mode 100644 index 00000000..2b421491 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt @@ -0,0 +1,67 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.feature.player.state.PlayerGestureState +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlayerControlVisibilityTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tapShowsControlsAfterAutomaticHideAndKeepsToggling() { + val visible = mutableStateOf(true) + val player = GestureTestPlayer(Looper.getMainLooper()) + val gestures = PlayerGestureState() + composeRule.setContent { + val currentVisibility = visible.value + PlayerGestureLayer( + player = player, + state = gestures, + onSingleTap = { visible.value = !currentVisibility }, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = true, + modifier = Modifier.size(600.dp, 300.dp).testTag("gesture"), + ) + } + tap() + composeRule.runOnIdle { assertFalse(visible.value) } + composeRule.runOnIdle { visible.value = true } + composeRule.runOnIdle { visible.value = false } + tap() + composeRule.runOnIdle { assertTrue(visible.value) } + tap() + composeRule.runOnIdle { assertFalse(visible.value) } + tap() + composeRule.runOnIdle { + assertTrue(visible.value) + player.release() + } + } + + private fun tap() { + composeRule.onNodeWithTag("gesture").performTouchInput { + advanceEventTime(500L) + click() + } + composeRule.mainClock.advanceTimeBy(500L) + composeRule.waitForIdle() + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index dc92b42e..10bfd1da 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -1,18 +1,38 @@ package dev.typetype.android.feature.player.components import android.os.Looper +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.ViewCompat +import androidx.core.graphics.Insets +import android.graphics.Bitmap +import java.io.File import dev.typetype.android.R +import dev.typetype.android.domain.stream.StreamStoryboard import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -30,6 +50,7 @@ import org.junit.runner.RunWith class PlayerControlsLayoutTest { @get:Rule val composeRule = createAndroidComposeRule() + private lateinit var controlsView: View @Test fun portraitControlsDoNotOverlapInsideShortVideoViewport() { @@ -37,9 +58,110 @@ class PlayerControlsLayoutTest { assertControlsDoNotOverlap() } + @Test + fun landscapePhoneKeepsNormalFullscreenControlSize() { + setControls(800.dp, 360.dp, smallestWidthDp = 360, fullscreen = true) + assertControlsDoNotOverlap() + val height = composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) + .fetchSemanticsNode().boundsInRoot.height + assertEquals(with(composeRule.density) { 74.dp.toPx() }, height, 1f) + } + + @Test + fun fullscreenTopControlsStayAnchoredWhenStatusBarsDisappear() { + composeRule.runOnUiThread { + val window = composeRule.activity.window + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowCompat.getInsetsController(window, window.decorView) + .show(WindowInsetsCompat.Type.statusBars()) + } + setControls(800.dp, 360.dp, fullscreen = true) + dispatchStatusBarInsets(40) + composeRule.waitForIdle() + val visibleTop = backButtonTopInViewport() + captureControls("status-bars-visible.png") + dispatchStatusBarInsets(0) + composeRule.waitForIdle() + captureControls("status-bars-hidden.png") + assertEquals(visibleTop, backButtonTopInViewport(), 1f) + } + + @Test + fun timelineScrubShowsStoryboardPreviewAndTimecode() { + val player = controlsLayoutPlayer( + durationMs = 600_000L, + positionMs = 120_000L, + ) + val storyboard = StreamStoryboard( + urls = listOf("https://example.test/storyboard.jpg"), + frameWidth = 160, + frameHeight = 90, + totalCount = 100, + durationPerFrameMillis = 5_000L, + framesPerPageX = 5, + framesPerPageY = 5, + ) + composeRule.setContent { + PlayerTimeBar( + player = player, + storyboard = storyboard, + modifier = Modifier + .fillMaxWidth() + .padding(top = 200.dp), + ) + } + } + + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_timeline), + ).performTouchInput { + down(Offset(width * 0.25f, center.y)) + moveBy(Offset(width * 0.5f, 0f)) + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG) + .assertIsDisplayed() + val previewTexts = composeRule.onNodeWithTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG) + .fetchSemanticsNode().children + .mapNotNull { child -> + child.config.getOrElse(SemanticsProperties.Text) { emptyList() }.singleOrNull() + } + assertTrue(previewTexts.single().matches(Regex("^\\d{1,2}:\\d{2}$"))) + } + + private fun dispatchStatusBarInsets(top: Int) { + composeRule.runOnUiThread { + ViewCompat.dispatchApplyWindowInsets( + controlsView, + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, top, 0, 0)) + .setVisible(WindowInsetsCompat.Type.statusBars(), top > 0) + .build(), + ) + } + } + + private fun backButtonTopInViewport(): Float { + val back = composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_back), + ).fetchSemanticsNode().boundsInRoot + val viewport = composeRule.onNodeWithTag(PLAYER_CONTROLS_VIEWPORT_TAG) + .fetchSemanticsNode().boundsInRoot + return back.top - viewport.top + } + + private fun captureControls(name: String) { + val bitmap = composeRule.onNodeWithTag(PLAYER_CONTROLS_VIEWPORT_TAG) + .captureToImage().asAndroidBitmap() + File(composeRule.activity.noBackupFilesDir, name).outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { - setControls(720.dp, 405.dp) + setControls(720.dp, 405.dp, 720) assertControlsDoNotOverlap() composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) .assertHeightIsAtLeast(96.dp) @@ -51,22 +173,34 @@ class PlayerControlsLayoutTest { ).assertHeightIsAtLeast(64.dp) } - private fun setControls(width: Dp, height: Dp) { + private fun setControls( + width: Dp, + height: Dp, + smallestWidthDp: Int = 360, + fullscreen: Boolean = false, + ) { val player = controlsLayoutPlayer() composeRule.setContent { - Box( - Modifier - .size(width = width, height = height) - .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), - ) { - PlayerControls( - player = player, - title = "Portrait controls", - onNavigateBack = {}, - isPipAvailable = true, - chaptersAvailable = true, - modifier = Modifier.matchParentSize(), - ) + controlsView = LocalView.current + val configuration = Configuration(LocalConfiguration.current).apply { + smallestScreenWidthDp = smallestWidthDp + } + CompositionLocalProvider(LocalConfiguration provides configuration) { + Box( + Modifier + .size(width = width, height = height) + .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), + ) { + PlayerControls( + player = player, + title = "Portrait controls", + onNavigateBack = {}, + isPipAvailable = true, + isFullscreen = fullscreen, + chaptersAvailable = true, + modifier = Modifier.matchParentSize(), + ) + } } } } @@ -92,13 +226,19 @@ class PlayerControlsLayoutTest { } } -private fun controlsLayoutPlayer(): Player = Proxy.newProxyInstance( +private fun controlsLayoutPlayer( + durationMs: Long = 0L, + positionMs: Long = 0L, +): Player = Proxy.newProxyInstance( Player::class.java.classLoader, arrayOf(Player::class.java), ) { _, method, _ -> when (method.name) { "getApplicationLooper" -> Looper.getMainLooper() "getAvailableCommands" -> Player.Commands.EMPTY + "getDuration" -> durationMs + "getCurrentPosition" -> positionMs + "getBufferedPosition" -> positionMs "getCurrentTimeline" -> Timeline.EMPTY "getPlaybackParameters" -> PlaybackParameters.DEFAULT "getCurrentTracks" -> Tracks.EMPTY diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt index 4a7a7d9a..aa82b20c 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -20,6 +21,7 @@ import androidx.compose.ui.test.swipe import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.PlaybackParameters import androidx.media3.common.SimpleBasePlayer import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.util.concurrent.Futures @@ -37,6 +39,120 @@ class PlayerGestureLayerTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun seekingCanSwitchToFinePrecisionWithoutJumpingOrChangingLevels() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val state = PlayerGestureState() + val levels = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = state, + onSingleTap = {}, + onAdjustBrightness = { levels.incrementAndGet() }, + onAdjustVolume = { levels.incrementAndGet() }, + isFullscreen = true, + config = PlayerGestureConfig(swipeSeekEnabled = true), + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(Offset(width * 0.25f, center.y)) + moveBy(Offset(width * 0.25f, 0f), delayMillis = 80) + } + var coarseTarget = 0L + composeRule.runOnIdle { + coarseTarget = state.seekDragTargetMs.longValue + assertTrue(coarseTarget > 120_000) + assertEquals(20_000L, player.currentPosition) + } + val fineDrop = with(composeRule.density) { 60.dp.toPx() } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(0f, fineDrop), delayMillis = 80) + } + composeRule.runOnIdle { + assertTrue(state.fineSeeking.value) + assertEquals(coarseTarget, state.seekDragTargetMs.longValue) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(10f, 0f), delayMillis = 80) + up() + } + composeRule.runOnIdle { + assertTrue(player.currentPosition > coarseTarget) + assertTrue(player.currentPosition <= coarseTarget + 1000) + assertEquals(0, levels.get()) + player.release() + } + } + + @Test + fun longPressDragChangesSpeedWithoutChangingLevelsAndRestoresOnRelease() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val levelChanges = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = { levelChanges.incrementAndGet() }, + onAdjustVolume = { levelChanges.incrementAndGet() }, + isFullscreen = true, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.runOnIdle { player.setPlaybackSpeed(1.5f) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(center) + advanceEventTime(700) + moveTo(center) + } + composeRule.runOnIdle { assertEquals(2f, player.playbackParameters.speed) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(0f, -height * 0.3f)) + } + composeRule.runOnIdle { + assertTrue(player.playbackParameters.speed > 2f) + assertEquals(0, levelChanges.get()) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { up() } + composeRule.runOnIdle { + assertEquals(1.5f, player.playbackParameters.speed) + player.release() + } + } + + @Test + fun removingGestureLayerDuringHoldRestoresPreviousSpeed() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val visible = mutableStateOf(true) + composeRule.setContent { + if (visible.value) PlayerGestureLayer( + player = player, + state = remember { PlayerGestureState() }, + onSingleTap = {}, + onAdjustBrightness = {}, + onAdjustVolume = {}, + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.runOnIdle { player.setPlaybackSpeed(1.25f) } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(center) + advanceEventTime(700) + moveTo(center) + } + composeRule.runOnIdle { + assertEquals(2f, player.playbackParameters.speed) + visible.value = false + } + composeRule.waitForIdle() + composeRule.runOnIdle { + assertEquals(1.25f, player.playbackParameters.speed) + player.release() + } + } + @Test fun doubleTapZonesSeekTogglePlaybackAndProvideFeedback() { val player = GestureTestPlayer(Looper.getMainLooper()) @@ -161,13 +277,15 @@ class PlayerGestureLayerTest { } } -private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { +internal class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { private var positionMs = 20_000L private var playWhenReady = false + private var parameters = PlaybackParameters.DEFAULT override fun getState(): State = State.Builder() .setAvailableCommands(Player.Commands.Builder().addAllCommands().build()) .setPlaybackState(Player.STATE_READY) + .setPlaybackParameters(parameters) .setPlayWhenReady( playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, @@ -175,6 +293,7 @@ private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { .setPlaylist( listOf( MediaItemData.Builder("gesture-item") + .setDurationUs(600_000_000L) .setMediaItem(MediaItem.Builder().setMediaId("gesture-item").build()) .build(), ), @@ -199,4 +318,10 @@ private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { } override fun handleRelease(): ListenableFuture<*> = Futures.immediateVoidFuture() + + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + parameters = playbackParameters + invalidateState() + return Futures.immediateVoidFuture() + } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/search/SearchScreenStateTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/search/SearchScreenStateTest.kt index 00c8c3c1..df58d2ca 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/search/SearchScreenStateTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/search/SearchScreenStateTest.kt @@ -1,12 +1,15 @@ package dev.typetype.android.feature.search import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import dev.typetype.android.core.ui.components.LocalAnimatedStatePlayback import dev.typetype.android.core.ui.theme.TypeTypeTheme +import dev.typetype.android.domain.search.SearchFilterOption import dev.typetype.android.feature.menu.VideoMenuScope import org.junit.Rule import org.junit.Test @@ -49,6 +52,40 @@ class SearchScreenStateTest { composeRule.onNodeWithText("Recent searches").assertIsDisplayed() composeRule.onNodeWithText("Compose accessibility").assertIsDisplayed() + composeRule.onNodeWithContentDescription("Clear all search history").assertIsDisplayed() + } + + @Test + fun duplicateRecentSearchesHaveUniqueLazyListKeys() { + show(SearchState(searchHistory = listOf("Compose", "Compose"))) + + composeRule.onAllNodesWithText("Compose").assertCountEquals(2) + } + + @Test + fun filtersStayHiddenUntilSearchFinishesSubmitting() { + show(searchState()) + + composeRule.onNodeWithContentDescription("Content type").assertDoesNotExist() + } + + @Test + fun filtersAppearOnlyWithResultsState() { + composeRule.setContent { + TypeTypeTheme { + SearchFilterBar( + contentFilters = searchState().contentFilters, + filterGroups = emptyList(), + selectedContent = null, + selectedFilters = emptyList(), + onContentSelect = {}, + onFilterToggle = { _, _ -> }, + onResetFilters = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("Content type").assertIsDisplayed() } @Test @@ -96,4 +133,11 @@ class SearchScreenStateTest { watchLater = emptySet(), onAction = { _, _ -> }, ) + + private fun searchState() = SearchState( + query = "kotlin", + contentFilters = listOf( + SearchFilterOption(value = "video", label = "video", isDefault = false), + ), + ) } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/search/SearchServiceSelectorTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/search/SearchServiceSelectorTest.kt new file mode 100644 index 00000000..1062420c --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/search/SearchServiceSelectorTest.kt @@ -0,0 +1,30 @@ +package dev.typetype.android.feature.search + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import dev.typetype.android.core.ui.theme.TypeTypeTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class SearchServiceSelectorTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun selectingAServiceReportsItsAccountId() { + var selectedService = -1 + composeRule.setContent { + TypeTypeTheme { + SearchServiceSelector(service = 0) { service -> + selectedService = service + } + } + } + + composeRule.onNodeWithText("NicoNico").performClick() + + assertEquals(6, selectedService) + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt index 80b27254..3da84b1a 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt @@ -1,7 +1,10 @@ package dev.typetype.android.feature.settings.appearance +import android.os.Build import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotSelected import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasText @@ -14,9 +17,11 @@ import androidx.compose.ui.test.performScrollToIndex import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.preferences.AccentColor import dev.typetype.android.domain.preferences.AppPreferences +import dev.typetype.android.domain.preferences.AppearancePersonality import dev.typetype.android.domain.preferences.AppearanceTheme import java.util.concurrent.atomic.AtomicReference import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test @@ -69,4 +74,43 @@ class AppearanceScreenTest { assertEquals(AppearanceAction.SelectTheme(AppearanceTheme.Forest), selected.get()) } + + @Test + fun accentCellsAreDisabledWhenDynamicColorsAreActive() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + + composeRule.setContent { + TypeTypeTheme { + AppearanceScreen( + state = AppPreferences(appearanceTheme = AppearanceTheme.Dynamic), + onAction = {}, + onNavigateBack = {}, + ) + } + } + + composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11) + composeRule.onNode(isSelectable() and hasText("Red")) + .assertIsNotEnabled() + } + + @Test + fun accentCellsRemainEnabledForMangaDynamicTheme() { + composeRule.setContent { + TypeTypeTheme { + AppearanceScreen( + state = AppPreferences( + appearancePersonality = AppearancePersonality.Manga, + appearanceTheme = AppearanceTheme.Dynamic, + ), + onAction = {}, + onNavigateBack = {}, + ) + } + } + + composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11) + composeRule.onNode(isSelectable() and hasText("Red")) + .assertIsEnabled() + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0927a54b..afb26eea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,14 @@ android:name=".feature.player.components.PictureInPictureActionReceiver" android:exported="false" /> + + + + + + 0 + val appNavigationVisible = shouldShowAppNavigation( + showsNavigation = showsNavigation, + isPlayerFullscreen = isPlayerFullscreen, + imeVisible = imeVisible, + ) var playerTransitionProgress by remember { mutableFloatStateOf(0f) } val playerHostState by playerHostController.state.collectAsStateWithLifecycle() val appChromeVisible = isAppChromeVisible(playerHostState.target, isPlayerFullscreen) @@ -164,7 +171,7 @@ fun AppShell( } }, bottomBar = { - if (showsNavigation && !isPlayerFullscreen) { + if (appNavigationVisible) { AppBottomBar( expanded = tabletLayout, selectedTabRouteQualifiedName = selectedTabRouteQualifiedName, @@ -199,14 +206,15 @@ fun AppShell( } PlayerHost( controller = playerHostController, - reserveNavigationBarInset = !tabletLayout || !showsNavigation || isPlayerFullscreen, + reserveNavigationBarInset = (!tabletLayout && !imeVisible) || + !showsNavigation || isPlayerFullscreen, modifier = Modifier.padding( - bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + bottom = if (tabletLayout && appNavigationVisible) bottomNavigationHeight else 0.dp, ).consumeWindowInsets(PaddingValues( - bottom = if (tabletLayout && showsNavigation && !isPlayerFullscreen) bottomNavigationHeight else 0.dp, + bottom = if (tabletLayout && appNavigationVisible) bottomNavigationHeight else 0.dp, )), bottomBarHeightDp = if ( - !tabletLayout && showsNavigation && !isPlayerFullscreen + !tabletLayout && appNavigationVisible ) { NAV_BAR_HEIGHT_DP } else { @@ -241,6 +249,12 @@ internal fun isAppChromeVisible( isPlayerFullscreen: Boolean, ): Boolean = playerTarget != PlayerHostTarget.Expanded && !isPlayerFullscreen +internal fun shouldShowAppNavigation( + showsNavigation: Boolean, + isPlayerFullscreen: Boolean, + imeVisible: Boolean, +): Boolean = showsNavigation && !isPlayerFullscreen && !imeVisible + internal fun playerPhoneChromeAlpha( hasVideo: Boolean, playerTarget: PlayerHostTarget, diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt index 2fe55708..bfaeaaf7 100644 --- a/app/src/main/java/dev/typetype/android/MainViewModel.kt +++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt @@ -27,6 +27,7 @@ import dev.typetype.android.domain.subscriptions.SubscriptionsRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.host.PlayerHostController +import dev.typetype.android.services.push.PushRegistrationManager import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -63,6 +64,7 @@ class MainViewModel @Inject constructor( private val subscriptionsRepository: SubscriptionsRepository, private val libraryRepository: LibraryRepository, private val activeAccountScope: ActiveAccountScope, + private val pushRegistrationManager: PushRegistrationManager, private val startupLandingStore: StartupLandingStore, private val playbackResumeRepository: PlaybackResumeRepository, private val playbackQueueRepository: PlaybackQueueRepository, @@ -182,6 +184,7 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } + launch { pushRegistrationManager.reconcileRegistration() } } } @@ -221,6 +224,7 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } + launch { pushRegistrationManager.reconcileRegistration() } launch { restorePlaybackUnlessExternalRequestArrives() } diff --git a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt index 231d9292..90cad85c 100644 --- a/app/src/main/java/dev/typetype/android/SettingsNavigation.kt +++ b/app/src/main/java/dev/typetype/android/SettingsNavigation.kt @@ -21,6 +21,7 @@ import dev.typetype.android.feature.settings.SettingsRoute as SettingsRouteScree import dev.typetype.android.feature.settings.about.LicensesScreen import dev.typetype.android.feature.settings.appearance.AppearanceRoute as AppearanceScreen import dev.typetype.android.feature.settings.imports.PortabilityRoute as ImportDataRouteScreen +import dev.typetype.android.feature.settings.notifications.PushNotificationsRoute as PushNotificationsRouteScreen import dev.typetype.android.feature.settings.rss.RssFeedsRoute as RssFeedsRouteScreen internal fun NavGraphBuilder.settingsDestinations( @@ -40,6 +41,7 @@ internal fun NavGraphBuilder.settingsDestinations( onOpenAppearance = { navController.navigate(AppearanceRoute) }, onOpenContent = { navController.navigate(ContentSettingsRoute) }, onOpenPlayer = { navController.navigate(PlayerSettingsRoute) }, + onOpenPushNotifications = { navController.navigate(dev.typetype.android.core.ui.navigation.PushNotificationsRoute) }, onOpenStorage = { navController.navigate(StorageSettingsRoute) }, onOpenPrivacy = { navController.navigate(PrivacySettingsRoute) }, onOpenDiagnostics = { navController.navigate(DiagnosticsRoute) }, @@ -59,6 +61,11 @@ internal fun NavGraphBuilder.settingsDestinations( onNavigateBack = { navController.popBackStack() }, ) } + composable { + PushNotificationsRouteScreen( + onNavigateBack = { navController.popBackStack() }, + ) + } composable { RssFeedsRouteScreen( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/dev/typetype/android/TypeTypeApp.kt b/app/src/main/java/dev/typetype/android/TypeTypeApp.kt index 38a4b075..93bdd7a9 100644 --- a/app/src/main/java/dev/typetype/android/TypeTypeApp.kt +++ b/app/src/main/java/dev/typetype/android/TypeTypeApp.kt @@ -9,6 +9,8 @@ import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import coil3.memory.MemoryCache import coil3.memoryCacheMaxSizePercentWhileInBackground +import coil3.disk.DiskCache +import okio.Path.Companion.toPath import coil3.svg.SvgDecoder import dagger.hilt.android.HiltAndroidApp import androidx.hilt.work.HiltWorkerFactory @@ -44,6 +46,12 @@ internal fun createTypeTypeImageLoader(context: PlatformContext): ImageLoader = .maxSizePercent(context, IMAGE_MEMORY_CACHE_PERCENT) .build() } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("coil_image_cache").absolutePath.toPath()) + .maxSizeBytes(DISK_CACHE_MAX_BYTES) + .build() + } .memoryCacheMaxSizePercentWhileInBackground(BACKGROUND_CACHE_RETAINED_PERCENT) .components { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -57,3 +65,4 @@ internal fun createTypeTypeImageLoader(context: PlatformContext): ImageLoader = private const val IMAGE_MEMORY_CACHE_PERCENT = 0.10 private const val BACKGROUND_CACHE_RETAINED_PERCENT = 0.50 +private const val DISK_CACHE_MAX_BYTES = 100L * 1024 * 1024 diff --git a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt index 5300a143..6675b24a 100644 --- a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt +++ b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt @@ -28,6 +28,7 @@ import dev.typetype.android.data.network.PlaybackNetworkMonitor import dev.typetype.android.data.network.PlaybackNetworkObserver import dev.typetype.android.data.preferences.DataStorePreferencesRepository import dev.typetype.android.data.podcast.RemotePodcastRepository +import dev.typetype.android.data.push.RemotePushRepository import dev.typetype.android.data.playback.RoomPlaybackResumeRepository import dev.typetype.android.data.playback.RoomPlaybackQueueRepository import dev.typetype.android.data.profile.RemoteProfileRepository @@ -72,6 +73,7 @@ import dev.typetype.android.domain.imports.YoutubeTakeoutImportRepository import dev.typetype.android.domain.library.VideoMetaRepository import dev.typetype.android.domain.notifications.NotificationsRepository import dev.typetype.android.domain.preferences.PreferencesRepository +import dev.typetype.android.domain.push.PushRepository import dev.typetype.android.domain.podcast.PodcastRepository import dev.typetype.android.domain.playback.PlaybackResumeRepository import dev.typetype.android.domain.playback.PlaybackQueueRepository @@ -173,6 +175,10 @@ abstract class RepositoryModule { impl: RemoteNotificationsRepository, ): NotificationsRepository + @Binds + @Singleton + abstract fun bindPushRepository(impl: RemotePushRepository): PushRepository + @Binds @Singleton internal abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 35362233..3d2bacf2 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -1,5 +1,6 @@ package dev.typetype.android.core.ui.components +import android.text.format.DateUtils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -39,6 +40,7 @@ import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt +import dev.typetype.android.domain.feed.releaseTimeMillis @OptIn(ExperimentalFoundationApi::class) @Composable @@ -137,7 +139,7 @@ fun RelatedVideoCard( ) } Text( - text = stringResource(R.string.video_views_short, formatRelatedViews(video.viewCount)), + text = video.metadataText(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -169,3 +171,19 @@ private fun formatRelatedViews(views: Long): String = when { views >= 1_000 -> "%.1fK".format(views / 1_000.0) else -> views.toString() } + +@Composable +private fun Video.metadataText(): String { + val views = stringResource(R.string.video_views_short, formatRelatedViews(viewCount)) + val published = releaseTimeMillis() + ?.takeIf { it <= System.currentTimeMillis() } + ?.let { timestamp -> + DateUtils.getRelativeTimeSpanString( + timestamp, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ).toString() + } + return listOfNotNull(views, published).joinToString(" · ") +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index b506092b..d0e300fd 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -38,7 +38,10 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -91,7 +94,10 @@ fun VideoCard( .background(MaterialTheme.colorScheme.surfaceVariant), ) { AsyncImage( - model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), + model = ImageRequest.Builder(LocalContext.current) + .data(buildImageUrl(serverBaseUrl, branding.thumbnailUrl)) + .crossfade(200) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), diff --git a/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt b/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt index f0461723..5bb38369 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/navigation/Routes.kt @@ -50,6 +50,9 @@ data object ContentSettingsRoute @Serializable data object PlayerSettingsRoute +@Serializable +data object PushNotificationsRoute + @Serializable data object StorageSettingsRoute diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt index 051b0297..cd268128 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeNotificationsApi.kt @@ -2,10 +2,18 @@ package dev.typetype.android.data.network import dev.typetype.android.data.network.dto.MarkNotificationsReadResponseDto import dev.typetype.android.data.network.dto.NotificationsResponseDto +import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceDto +import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto +import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto +import dev.typetype.android.data.network.dto.PushDeviceRegistrationResponseDto import dev.typetype.android.data.network.dto.UnreadNotificationsCountDto import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path import retrofit2.http.Query interface TypeTypeNotificationsApi { @@ -20,4 +28,25 @@ interface TypeTypeNotificationsApi { @POST("notifications/read-all") suspend fun markAllNotificationsRead(): Response + + @POST("notifications/push/devices") + suspend fun registerPushDevice( + @Body request: PushDeviceRegistrationRequestDto, + ): Response + + @GET("notifications/push/devices") + suspend fun pushDevices(): Response> + + @DELETE("notifications/push/devices/{deviceId}") + suspend fun deletePushDevice( + @Path("deviceId") deviceId: String, + ): Response + + @GET("notifications/channel-preferences") + suspend fun channelNotificationPreferences(): Response> + + @PUT("notifications/channel-preferences") + suspend fun setChannelNotificationPreference( + @Body request: ChannelNotificationPreferenceRequestDto, + ): Response } diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeSearchApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeSearchApi.kt index aad22330..a6dd5e79 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeSearchApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeSearchApi.kt @@ -4,6 +4,7 @@ import dev.typetype.android.data.network.dto.ChannelPageRequest import dev.typetype.android.data.network.dto.ChannelPlaylistsResponse import dev.typetype.android.data.network.dto.ChannelResponse import dev.typetype.android.data.network.dto.SearchHistoryEntryRequest +import dev.typetype.android.data.network.dto.SearchHistoryItemDto import dev.typetype.android.data.network.dto.SearchFiltersResponse import dev.typetype.android.data.network.dto.PublicPlaylistResponseDto import dev.typetype.android.data.network.dto.SearchResponse @@ -52,13 +53,10 @@ interface TypeTypeSearchApi { ): Response> @GET("search-history") - suspend fun searchHistory(): Response> + suspend fun searchHistory(): Response> @POST("search-history") - suspend fun addSearchHistory(@Body body: SearchHistoryEntryRequest): Response - - @DELETE("search-history") - suspend fun removeSearchHistory(@Query("query") query: String): Response + suspend fun addSearchHistory(@Body body: SearchHistoryEntryRequest): Response @DELETE("search-history") suspend fun clearSearchHistory(): Response diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt index 6f7a5389..217f8000 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/InstanceDto.kt @@ -30,6 +30,7 @@ data class InstanceResponse( val youtubeRemoteLoginReady: Boolean? = null, val youtubeRemoteLoginUnavailableReason: String? = null, val rss: RssInstanceCapabilityDto? = null, + val pushNotifications: PushCapabilityDto? = null, ) @Serializable @@ -41,6 +42,14 @@ data class RssInstanceCapabilityDto( val rateLimitPerMinute: Int = 0, ) +@Serializable +data class PushCapabilityDto( + val enabled: Boolean = false, + val provider: String = "unifiedpush", + val eventTypes: List = emptyList(), + val maxDevicesPerAccount: Int = 0, +) + @Serializable data class MinClientVersion( val android: String? = null, diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/InteractionDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/InteractionDto.kt index 16984ad2..9a73f431 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/InteractionDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/InteractionDto.kt @@ -36,9 +36,6 @@ data class ProgressItemDto( val updatedAt: Long = 0L, ) -@Serializable -data class SearchHistoryEntryRequest(val query: String) - @Serializable data class CreatePlaylistRequest( val name: String, diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt new file mode 100644 index 00000000..2824192f --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.data.network.dto + +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import dev.typetype.android.domain.push.PushDevice +import kotlinx.serialization.Serializable + +@Serializable +data class PushDeviceRegistrationRequestDto( + val deviceId: String, + val platform: String = "android", + val endpoint: String, + val expiresAt: Long? = null, +) + +@Serializable +data class PushDeviceRegistrationResponseDto( + val id: String, + val deviceId: String, + val platform: String, + val expiresAt: Long? = null, + val updatedAt: Long, +) + +@Serializable +data class ChannelNotificationPreferenceRequestDto( + val channelUrl: String, + val enabled: Boolean, +) + +@Serializable +data class ChannelNotificationPreferenceDto( + val channelUrl: String, + val enabled: Boolean, + val updatedAt: Long, +) + +internal fun PushDeviceRegistrationResponseDto.toDomain(): PushDevice = PushDevice( + deviceId = deviceId, + updatedAt = updatedAt, +) + +internal fun ChannelNotificationPreferenceDto.toDomain(): ChannelNotificationsPreference = + ChannelNotificationsPreference( + channelUrl = channelUrl, + enabled = enabled, + ) diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/SearchHistoryDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/SearchHistoryDto.kt new file mode 100644 index 00000000..2e4238da --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/network/dto/SearchHistoryDto.kt @@ -0,0 +1,13 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchHistoryItemDto( + val id: String = "", + val term: String = "", + val searchedAt: Long = 0L, +) + +@Serializable +data class SearchHistoryEntryRequest(val term: String) diff --git a/app/src/main/java/dev/typetype/android/data/push/PushInstanceNames.kt b/app/src/main/java/dev/typetype/android/data/push/PushInstanceNames.kt new file mode 100644 index 00000000..915b9846 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/push/PushInstanceNames.kt @@ -0,0 +1,11 @@ +package dev.typetype.android.data.push + +import dev.typetype.android.data.account.AccountScope + +internal fun pushInstanceName(scope: AccountScope): String = "${scope.serverId}:${scope.accountId}" + +internal fun scopeFromInstanceName(instance: String): AccountScope? { + val parts = instance.split(":", limit = 2) + if (parts.size != 2 || parts.any(String::isBlank)) return null + return AccountScope(serverId = parts[0], accountId = parts[1]) +} diff --git a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt new file mode 100644 index 00000000..46159fc0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt @@ -0,0 +1,68 @@ +package dev.typetype.android.data.push + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.typetype.android.data.account.AccountScope +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +@Singleton +class PushRegistrationStore @Inject constructor( + private val dataStore: DataStore, +) { + data class Registration( + val deviceId: String, + val endpoint: String?, + ) + + fun registration(scope: AccountScope): Flow = dataStore.data.map { prefs -> + prefs.registration(scope) + } + + suspend fun registrationOnce(scope: AccountScope): Registration? = + dataStore.data.first().registration(scope) + + suspend fun ensureDeviceId(scope: AccountScope): String { + dataStore.data.first()[deviceIdKey(scope)]?.takeIf(String::isNotBlank)?.let { return it } + val deviceId = UUID.randomUUID().toString() + dataStore.edit { prefs -> + if (prefs[deviceIdKey(scope)].isNullOrBlank()) { + prefs[deviceIdKey(scope)] = deviceId + } + } + return dataStore.data.first()[deviceIdKey(scope)] ?: error("The stored push device id disappeared") + } + + suspend fun setEndpoint(scope: AccountScope, endpoint: String?) { + dataStore.edit { prefs -> + val key = endpointKey(scope) + if (endpoint == null) prefs.remove(key) else prefs[key] = endpoint + } + } + + suspend fun clear(scope: AccountScope) { + dataStore.edit { prefs -> + prefs.remove(deviceIdKey(scope)) + prefs.remove(endpointKey(scope)) + } + } + + private fun Preferences.registration(scope: AccountScope): Registration? { + val deviceId = this[deviceIdKey(scope)]?.takeIf(String::isNotBlank) ?: return null + return Registration(deviceId, this[endpointKey(scope)]) + } + + private fun deviceIdKey(scope: AccountScope) = + stringPreferencesKey("push_device_id_${scopeKey(scope)}") + + private fun endpointKey(scope: AccountScope) = + stringPreferencesKey("push_endpoint_${scopeKey(scope)}") + + private fun scopeKey(scope: AccountScope) = "${scope.serverId}_${scope.accountId}" +} diff --git a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt new file mode 100644 index 00000000..7c5fe569 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt @@ -0,0 +1,97 @@ +package dev.typetype.android.data.push + +import dev.typetype.android.data.account.AccountDao +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.data.network.TypeTypeApiHolder +import dev.typetype.android.data.network.dto.ChannelNotificationPreferenceRequestDto +import dev.typetype.android.data.network.dto.PushDeviceRegistrationRequestDto +import dev.typetype.android.data.network.dto.toDomain +import dev.typetype.android.data.network.requireSuccessfulResponse +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import dev.typetype.android.domain.push.PushDevice +import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.domain.server.PushCapability +import dev.typetype.android.domain.server.ServerCapabilitiesRepository +import dev.typetype.android.domain.server.ServerRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Singleton +class RemotePushRepository @Inject constructor( + private val apiHolder: TypeTypeApiHolder, + private val activeAccountScope: ActiveAccountScope, + private val accountDao: AccountDao, + private val capabilitiesRepository: ServerCapabilitiesRepository, + private val serverRepository: ServerRepository, +) : PushRepository { + + override suspend fun currentCapability(): PushCapability { + val scope = activeAccountScope.require() + val refreshed = runCatching { capabilitiesRepository.refresh(scope.serverId).getOrThrow() } + return refreshed.getOrNull()?.push + ?: serverRepository.getServer(scope.serverId)?.push + ?: PushCapability() + } + + override suspend fun registerDevice(deviceId: String, endpoint: String): Result = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { + api.registerPushDevice( + PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint), + ) + } + response.requireSuccessfulResponse() + } + + override suspend fun unregisterDevice(deviceId: String): Result = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { api.deletePushDevice(deviceId) } + if (response.code() != 404) { + response.requireSuccessfulResponse() + } + } + + override suspend fun devices(): Result> = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { api.pushDevices() } + response.requireSuccessfulResponse() + response.body()?.map { it.toDomain() }.orEmpty() + } + + override suspend fun channelPreferences(): Result> = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { api.channelNotificationPreferences() } + response.requireSuccessfulResponse() + response.body()?.map { it.toDomain() }.orEmpty() + } + + override suspend fun setChannelPreference(channelUrl: String, enabled: Boolean): Result = guarded { + val api = apiHolder.require(requireEligibleScope()) + val response = withContext(Dispatchers.IO) { + api.setChannelNotificationPreference( + ChannelNotificationPreferenceRequestDto(channelUrl = channelUrl, enabled = enabled), + ) + } + response.requireSuccessfulResponse() + } + + private suspend fun requireEligibleScope(): AccountScope { + val scope = activeAccountScope.require() + val account = accountDao.get(scope.serverId, scope.accountId) + check(account != null && !account.isGuest) { + "Push notifications are unavailable for guest accounts" + } + return scope + } + + private inline fun guarded(block: () -> T): Result = try { + Result.success(block()) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + Result.failure(error) + } +} diff --git a/app/src/main/java/dev/typetype/android/data/searchhistory/RemoteSearchHistoryStore.kt b/app/src/main/java/dev/typetype/android/data/searchhistory/RemoteSearchHistoryStore.kt index 39f887e3..c30a1674 100644 --- a/app/src/main/java/dev/typetype/android/data/searchhistory/RemoteSearchHistoryStore.kt +++ b/app/src/main/java/dev/typetype/android/data/searchhistory/RemoteSearchHistoryStore.kt @@ -22,27 +22,21 @@ class RemoteSearchHistoryStore @Inject constructor( val response = withContext(Dispatchers.IO) { api.searchHistory() } response.requireSuccessfulResponse() activeAccountScope.verify(scope) - response.body() ?: emptyList() + response.body().orEmpty() + .map { it.term } + .filter { it.isNotBlank() } } override suspend fun addEntry(query: String): Result = runCatching { val scope = activeAccountScope.require() val api = apiHolder.require(scope) val response = withContext(Dispatchers.IO) { - api.addSearchHistory(SearchHistoryEntryRequest(query)) + api.addSearchHistory(SearchHistoryEntryRequest(term = query)) } response.requireSuccessfulResponse() activeAccountScope.verify(scope) } - override suspend fun removeEntry(query: String): Result = runCatching { - val scope = activeAccountScope.require() - val api = apiHolder.require(scope) - val response = withContext(Dispatchers.IO) { api.removeSearchHistory(query) } - response.requireSuccessfulResponse() - activeAccountScope.verify(scope) - } - override suspend fun clearHistory(): Result = runCatching { val scope = activeAccountScope.require() val response = withContext(Dispatchers.IO) { apiHolder.require(scope).clearSearchHistory() } diff --git a/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt b/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt index 94d44fb6..177862d9 100644 --- a/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/server/RemoteServerCapabilitiesRepository.kt @@ -2,8 +2,10 @@ package dev.typetype.android.data.server import dev.typetype.android.data.network.RetrofitFactory import dev.typetype.android.data.network.dto.InstanceResponse +import dev.typetype.android.data.network.dto.PushCapabilityDto import dev.typetype.android.data.network.requireSuccessfulResponse import dev.typetype.android.domain.server.RssCapability +import dev.typetype.android.domain.server.PushCapability import dev.typetype.android.domain.server.Server import dev.typetype.android.domain.server.ServerCapabilitiesRepository import dev.typetype.android.domain.server.ServerRepository @@ -62,6 +64,14 @@ internal fun Server.withCapabilities(instance: InstanceResponse): Server = copy( rateLimitPerMinute = it.rateLimitPerMinute, ) } ?: RssCapability(), + push = instance.pushNotifications.toCapability(), +) + +private fun PushCapabilityDto?.toCapability(): PushCapability = PushCapability( + enabled = this?.enabled == true, + provider = this?.provider ?: "unifiedpush", + eventTypes = this?.eventTypes ?: emptyList(), + maxDevicesPerAccount = this?.maxDevicesPerAccount ?: 0, ) private suspend fun captureResult(block: suspend () -> T): Result = try { diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt index 95334985..c7db219d 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt @@ -18,6 +18,7 @@ internal class SabrPlaybackSessionPreparer( private val pause: suspend (Long) -> Unit = { delay(it) }, private val network: PlaybackNetworkObserver = AlwaysAvailablePlaybackNetworkObserver, private val maxWindowPolls: Int = 60, + private val stuckRecoveryAttempts: Int = STUCK_RECOVERY_ATTEMPTS, ) { suspend fun prepare( api: TypeTypeMediaApi, @@ -80,7 +81,12 @@ internal class SabrPlaybackSessionPreparer( val control = response.body() ?.requireControlResponse(target, binding.sessionId, binding.generation) ?: sabrContractMismatch("SABR returned an empty seek response") - return waitForWindow(api, baseUrl, target, control, emptyList()) + return try { + waitForWindow(api, baseUrl, target, control, emptyList()) + } catch (failure: SabrControlException) { + if (failure.failureCode != "youtube_sabr_preparation_timeout") throw failure + createSession(api, baseUrl, target, startTimeMs) + } } suspend fun refresh( @@ -214,6 +220,12 @@ internal class SabrPlaybackSessionPreparer( } else { stagnantAttempts = pending.stagnantAttempts(previousEdgeMs, stagnantAttempts) previousEdgeMs = pending.bufferedEdgeMs + if (stagnantAttempts >= stuckRecoveryAttempts) { + throw sabrPreparationFailure( + "SABR prefetch stuck: segment-pending", + "youtube_sabr_preparation_timeout", + ) + } pause(pending.retryDelay(stagnantAttempts)) } } @@ -281,6 +293,8 @@ internal class SabrPlaybackSessionPreparer( } +private const val STUCK_RECOVERY_ATTEMPTS = 5 + private const val MAX_FRESH_SESSION_RECOVERIES = 2 private fun SabrPlaybackRecoveryException.nextTarget( diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index 27125201..7d81fb1c 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -4,6 +4,7 @@ import dev.typetype.android.data.account.AccountScopeProvider import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkObserver import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.PreviewFrameItem import dev.typetype.android.data.network.dto.SponsorBlockSegmentItem import dev.typetype.android.data.network.dto.StreamResponse import dev.typetype.android.data.network.dto.StreamSegmentItem @@ -19,6 +20,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.StreamRepository +import dev.typetype.android.domain.stream.StreamStoryboard import dev.typetype.android.domain.stream.StreamRequestScope import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.domain.stream.isServerSabrAudioFormat @@ -176,6 +178,9 @@ internal class StreamRepositoryImpl @Inject constructor( subtitles = subtitles.mapIndexedNotNull { index, subtitle -> subtitle.toClientSubtitleSource(index) }, + storyboard = previewFrames + .mapNotNull { it.toDomainStoryboard() } + .let(::selectStoryboard), startPositionMillis = startPosition * 1000L, sponsorBlockSegments = sponsorBlockSegments.mapNotNull { it.toDomainSponsorBlockSegment(duration) @@ -202,6 +207,22 @@ internal class StreamRepositoryImpl @Inject constructor( .maxByOrNull { it.height } ?.url + private fun selectStoryboard(storyboards: List): StreamStoryboard? = + storyboards.filter { it.frameWidth >= TARGET_STORYBOARD_FRAME_WIDTH } + .minByOrNull { it.frameWidth } + ?: storyboards.maxByOrNull { it.frameWidth } + + private fun PreviewFrameItem.toDomainStoryboard(): StreamStoryboard? = + StreamStoryboard( + urls = urls.filter(String::isNotBlank), + frameWidth = frameWidth, + frameHeight = frameHeight, + totalCount = totalCount, + durationPerFrameMillis = durationPerFrame.toLong(), + framesPerPageX = framesPerPageX, + framesPerPageY = framesPerPageY, + ).takeIf { it.frameAt(0L) != null } + private fun serverManifestUrl(baseUrl: String, path: String, videoUrl: String): String { val normalizedBaseUrl = baseUrl.trimEnd('/') val encoded = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8) @@ -273,6 +294,7 @@ internal suspend fun cancellableStreamResult( } private const val SABR_DELIVERY_METHOD = "sabr" +private const val TARGET_STORYBOARD_FRAME_WIDTH = 160 private class SabrContractException : IllegalStateException("The server returned no playable SABR contract"), diff --git a/app/src/main/java/dev/typetype/android/domain/navigation/IncomingVideoUrl.kt b/app/src/main/java/dev/typetype/android/domain/navigation/IncomingVideoUrl.kt index 081d2ad2..b0da1074 100644 --- a/app/src/main/java/dev/typetype/android/domain/navigation/IncomingVideoUrl.kt +++ b/app/src/main/java/dev/typetype/android/domain/navigation/IncomingVideoUrl.kt @@ -20,6 +20,32 @@ fun toPublicWatchParameter(value: String): String { fun canonicalVideoIdentity(value: String): String = resolveIncomingVideoUrl(value) ?: value.trim() +fun sameVideoTimestampMillis(url: String, currentVideoUrl: String): Long? { + val trimmed = url.trim() + if (trimmed.isEmpty()) return null + val timestamp = timestampParamMillis(trimmed) ?: return null + val identity = canonicalVideoIdentity(trimmed) + if (identity != canonicalVideoIdentity(currentVideoUrl)) return null + return timestamp +} + +private fun timestampParamMillis(url: String): Long? { + val uri = runCatching { URI(url) }.getOrNull() ?: return null + val raw = queryParameter(uri, "t") + ?: queryParameter(uri, "start") + ?: queryParameter(uri, "timestamp") + ?: return null + val compact = raw.trim().removeSuffix("s").removeSuffix("S") + if (compact.isEmpty()) return null + val match = DURATION_PATTERN.matchEntire(compact) ?: return null + val hours = match.groupValues[1].toLongOrNull() ?: 0L + val minutes = match.groupValues[2].toLongOrNull() ?: 0L + val seconds = match.groupValues[3].toLongOrNull() ?: return null + return ((hours * 3600L) + (minutes * 60L) + seconds) * 1000L +} + +private val DURATION_PATTERN = Regex("(?:(\\d{1,3})h)?(?:(\\d{1,3})m)?(\\d{1,5})") + fun resolveSharedVideoUrl(value: String?): String? { val text = value?.trim().orEmpty() if (text.isEmpty() || text.length > MAX_SHARED_TEXT_LENGTH) return null diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt new file mode 100644 index 00000000..08befe8e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt @@ -0,0 +1,25 @@ +package dev.typetype.android.domain.push + +data class PushDevice( + val deviceId: String, + val updatedAt: Long, +) + +data class ChannelNotificationsPreference( + val channelUrl: String, + val enabled: Boolean, +) + +sealed interface PushRegistrationStatus { + data object Disabled : PushRegistrationStatus + + data object Unavailable : PushRegistrationStatus + + data object MissingDistributor : PushRegistrationStatus + + data object Registering : PushRegistrationStatus + + data object Registered : PushRegistrationStatus + + data object Failed : PushRegistrationStatus +} diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushPayload.kt b/app/src/main/java/dev/typetype/android/domain/push/PushPayload.kt new file mode 100644 index 00000000..310f278b --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/push/PushPayload.kt @@ -0,0 +1,30 @@ +package dev.typetype.android.domain.push + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class PushPayload( + val version: Int = 1, + val eventType: String, + val serviceId: Int, + val serviceName: String, + val eventId: String, + val videoId: String, + val videoUrl: String, + val channelId: String, + val channelName: String, + val channelAvatarUrl: String, + val instanceId: String, + val accountId: String, + val publishedAt: Long, + val title: String, +) + +fun parsePushPayload(content: ByteArray): PushPayload? = runCatching { + payloadJson.decodeFromString(PushPayload.serializer(), content.decodeToString()) +}.getOrNull() + +private val payloadJson = Json { + ignoreUnknownKeys = true +} diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt new file mode 100644 index 00000000..7579a30a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt @@ -0,0 +1,17 @@ +package dev.typetype.android.domain.push + +import dev.typetype.android.domain.server.PushCapability + +interface PushRepository { + suspend fun currentCapability(): PushCapability + + suspend fun registerDevice(deviceId: String, endpoint: String): Result + + suspend fun unregisterDevice(deviceId: String): Result + + suspend fun devices(): Result> + + suspend fun channelPreferences(): Result> + + suspend fun setChannelPreference(channelUrl: String, enabled: Boolean): Result +} diff --git a/app/src/main/java/dev/typetype/android/domain/searchhistory/SearchHistoryRepository.kt b/app/src/main/java/dev/typetype/android/domain/searchhistory/SearchHistoryRepository.kt index 7db8c0d6..27c639e8 100644 --- a/app/src/main/java/dev/typetype/android/domain/searchhistory/SearchHistoryRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/searchhistory/SearchHistoryRepository.kt @@ -3,6 +3,5 @@ package dev.typetype.android.domain.searchhistory interface SearchHistoryRepository { suspend fun loadHistory(): Result> suspend fun addEntry(query: String): Result - suspend fun removeEntry(query: String): Result suspend fun clearHistory(): Result } diff --git a/app/src/main/java/dev/typetype/android/domain/server/PushCapability.kt b/app/src/main/java/dev/typetype/android/domain/server/PushCapability.kt new file mode 100644 index 00000000..c1124d05 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/domain/server/PushCapability.kt @@ -0,0 +1,8 @@ +package dev.typetype.android.domain.server + +data class PushCapability( + val enabled: Boolean = false, + val provider: String = "unifiedpush", + val eventTypes: List = emptyList(), + val maxDevicesPerAccount: Int = 0, +) diff --git a/app/src/main/java/dev/typetype/android/domain/server/Server.kt b/app/src/main/java/dev/typetype/android/domain/server/Server.kt index f3ae7193..d15674af 100644 --- a/app/src/main/java/dev/typetype/android/domain/server/Server.kt +++ b/app/src/main/java/dev/typetype/android/domain/server/Server.kt @@ -24,4 +24,5 @@ data class Server( val youtubeRemoteLoginReady: Boolean = false, val youtubeRemoteLoginUnavailableReason: String? = null, val rss: RssCapability = RssCapability(), + val push: PushCapability = PushCapability(), ) diff --git a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt index aeff9daa..fa142f66 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt @@ -33,6 +33,7 @@ data class Stream( val originalAudioTrackId: String? = null, val preferredDefaultAudioTrackId: String? = null, val subtitles: List = emptyList(), + val storyboard: StreamStoryboard? = null, val startPositionMillis: Long, val sponsorBlockSegments: List = emptyList(), val chapters: List = emptyList(), @@ -43,6 +44,50 @@ data class Stream( val category: String? = null, ) +data class StreamStoryboard( + val urls: List, + val frameWidth: Int, + val frameHeight: Int, + val totalCount: Int, + val durationPerFrameMillis: Long, + val framesPerPageX: Int, + val framesPerPageY: Int, +) { + fun frameAt(positionMs: Long): StreamStoryboardFrame? { + val totalFrames = totalCount.takeIf { it > 0 } ?: return null + val duration = durationPerFrameMillis.takeIf { it > 0L } ?: return null + val pageColumns = framesPerPageX.takeIf { it > 0 } ?: return null + val pageRows = framesPerPageY.takeIf { it > 0 } ?: return null + if (frameWidth <= 0 || frameHeight <= 0 || urls.isEmpty()) return null + + val elapsedFrames = positionMs / duration + val frameIndex = when { + elapsedFrames <= 0L -> 0 + elapsedFrames >= totalFrames - 1L -> totalFrames - 1 + else -> elapsedFrames.toInt() + } + val framesPerPage = pageColumns * pageRows + val pageIndex = (frameIndex / framesPerPage).coerceIn(0, urls.lastIndex) + val pageFrameIndex = frameIndex % framesPerPage + val url = urls.getOrNull(pageIndex) ?: return null + return StreamStoryboardFrame( + url = url, + x = (pageFrameIndex % pageColumns) * frameWidth, + y = (pageFrameIndex / pageColumns) * frameHeight, + width = frameWidth, + height = frameHeight, + ) + } +} + +data class StreamStoryboardFrame( + val url: String, + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) + enum class StreamPlaybackContract { ServerSabr, ProviderMedia, diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelAction.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelAction.kt index fdf6e55f..bc25de25 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelAction.kt @@ -7,6 +7,7 @@ sealed interface ChannelAction { data object OnLoadMore : ChannelAction data object OnLoadMorePlaylists : ChannelAction data object OnToggleSubscribe : ChannelAction + data object OnToggleNotifications : ChannelAction data object OnSubmitSearch : ChannelAction data object OnDismissSearch : ChannelAction data object OnClearSearchInput : ChannelAction diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt index ea8acbb5..da9f519b 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelHeader.kt @@ -1,5 +1,10 @@ package dev.typetype.android.feature.channel +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +24,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -30,12 +37,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -50,7 +59,11 @@ internal fun ChannelHeader( channel: Channel, isSubscribed: Boolean, subscribeInFlight: Boolean, + notificationsAvailable: Boolean, + notificationsEnabled: Boolean, + notificationsInFlight: Boolean, onToggleSubscribe: () -> Unit, + onToggleNotifications: () -> Unit, onNavigateBack: () -> Unit, ) { BoxWithConstraints(Modifier.fillMaxWidth()) { @@ -58,7 +71,11 @@ internal fun ChannelHeader( channel = channel, isSubscribed = isSubscribed, subscribeInFlight = subscribeInFlight, + notificationsAvailable = notificationsAvailable, + notificationsEnabled = notificationsEnabled, + notificationsInFlight = notificationsInFlight, onToggleSubscribe = onToggleSubscribe, + onToggleNotifications = onToggleNotifications, onNavigateBack = onNavigateBack, expanded = maxWidth >= 600.dp, ) @@ -70,11 +87,31 @@ private fun ChannelHeaderContent( channel: Channel, isSubscribed: Boolean, subscribeInFlight: Boolean, + notificationsAvailable: Boolean, + notificationsEnabled: Boolean, + notificationsInFlight: Boolean, onToggleSubscribe: () -> Unit, + onToggleNotifications: () -> Unit, onNavigateBack: () -> Unit, expanded: Boolean, ) { val serverBaseUrl = LocalServerBaseUrl.current + val context = LocalContext.current + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onToggleNotifications() + } + val requestNotifications: () -> Unit = { + val notificationsGranted = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (notificationsEnabled || notificationsGranted) { + onToggleNotifications() + } else { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } Column(modifier = Modifier.fillMaxWidth()) { if (channel.bannerUrl.isNullOrBlank()) { ChannelBackButton(onNavigateBack = onNavigateBack) @@ -120,6 +157,27 @@ private fun ChannelHeaderContent( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (notificationsAvailable) { + IconButton( + onClick = requestNotifications, + enabled = !notificationsInFlight, + ) { + Icon( + imageVector = if (notificationsEnabled) { + Icons.Filled.NotificationsActive + } else { + Icons.Filled.NotificationsOff + }, + contentDescription = stringResource( + if (notificationsEnabled) { + R.string.channel_notifications_disable + } else { + R.string.channel_notifications_enable + }, + ), + ) + } + } SubscribeButton( isSubscribed = isSubscribed, enabled = !subscribeInFlight, diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt new file mode 100644 index 00000000..939d3403 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.feature.channel + +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.domain.server.ServerRepository +import javax.inject.Inject + +class ChannelNotificationsController @Inject constructor( + private val pushRepository: PushRepository, + private val activeAccountScope: ActiveAccountScope, + private val serverRepository: ServerRepository, +) { + suspend fun isAvailable(isSubscribed: Boolean): Boolean { + if (!isSubscribed) return false + val scope = runCatching { activeAccountScope.require() }.getOrNull() ?: return false + return serverRepository.getServer(scope.serverId)?.push?.enabled == true + } + + suspend fun currentEnabled(channelUrl: String): Boolean? = + pushRepository.channelPreferences().getOrNull() + ?.firstOrNull { it.matches(channelUrl) } + ?.enabled + + suspend fun setEnabled(channelUrl: String, enabled: Boolean): Boolean? { + pushRepository.setChannelPreference(channelUrl, enabled).getOrNull() ?: return null + return enabled + } + + private fun ChannelNotificationsPreference.matches(channelUrl: String): Boolean = + this.channelUrl == channelUrl || + channelUrl.endsWith(this.channelUrl) || + this.channelUrl.endsWith(channelUrl) +} diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelScreen.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelScreen.kt index 07c363bd..850f92e5 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelScreen.kt @@ -149,7 +149,11 @@ internal fun ChannelContentGrid( channel = channel, isSubscribed = state.isSubscribed, subscribeInFlight = state.subscribeInFlight, + notificationsAvailable = state.notificationsAvailable, + notificationsEnabled = state.notificationsEnabled, + notificationsInFlight = state.notificationsInFlight, onToggleSubscribe = { onAction(ChannelAction.OnToggleSubscribe) }, + onToggleNotifications = { onAction(ChannelAction.OnToggleNotifications) }, onNavigateBack = onNavigateBack, ) } diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelState.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelState.kt index 65b776bd..788b4eab 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelState.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelState.kt @@ -26,6 +26,9 @@ data class ChannelState( val errorRequestId: String? = null, val isSubscribed: Boolean = false, val subscribeInFlight: Boolean = false, + val notificationsAvailable: Boolean = false, + val notificationsEnabled: Boolean = false, + val notificationsInFlight: Boolean = false, val podcasts: List = emptyList(), val podcastsLoading: Boolean = false, val playlists: List = emptyList(), @@ -37,3 +40,6 @@ data class ChannelState( val playlistsErrorMessage: String? = null, val playlistsErrorRequestId: String? = null, ) + +internal fun String.isYouTubeChannel(): Boolean = + contains("youtube.com", ignoreCase = true) || startsWith("/channel/") || startsWith("/c/") diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelViewModel.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelViewModel.kt index cf85bf78..26e34bd5 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelViewModel.kt @@ -29,6 +29,7 @@ class ChannelViewModel @Inject constructor( private val videoMetaRepository: VideoMetaRepository, private val subscriptionsRepository: SubscriptionsRepository, private val podcastRepository: PodcastRepository, + private val notifications: ChannelNotificationsController, private val errorMapper: UserErrorMapper, ) : ViewModel() { @@ -63,6 +64,7 @@ class ChannelViewModel @Inject constructor( ChannelAction.OnLoadMore -> loadMore() ChannelAction.OnLoadMorePlaylists -> loadMorePlaylists() ChannelAction.OnToggleSubscribe -> toggleSubscribe() + ChannelAction.OnToggleNotifications -> toggleNotifications() ChannelAction.OnSubmitSearch -> submitSearch() ChannelAction.OnDismissSearch -> { _state.update { it.copy(searchInput = it.appliedSearch) } @@ -268,6 +270,30 @@ class ChannelViewModel @Inject constructor( viewModelScope.launch { subscriptionsRepository.observeSubscribedChannelUrls().collect { urls -> _state.update { it.copy(isSubscribed = channelUrl in urls) } + refreshNotifications() + } + } + } + + private suspend fun refreshNotifications() { + val available = notifications.isAvailable(isSubscribed = _state.value.isSubscribed) + _state.update { it.copy(notificationsAvailable = available) } + if (!available) return + val enabled = notifications.currentEnabled(channelUrl) ?: return + _state.update { it.copy(notificationsEnabled = enabled) } + } + + private fun toggleNotifications() { + val current = _state.value + if (!current.notificationsAvailable || current.notificationsInFlight) return + viewModelScope.launch { + _state.update { it.copy(notificationsInFlight = true) } + val enabled = notifications.setEnabled(channelUrl, !current.notificationsEnabled) + _state.update { + it.copy( + notificationsInFlight = false, + notificationsEnabled = enabled ?: it.notificationsEnabled, + ) } } } @@ -300,6 +326,3 @@ class ChannelViewModel @Inject constructor( } } } - -internal fun String.isYouTubeChannel(): Boolean = - contains("youtube.com", ignoreCase = true) || startsWith("/channel/") || startsWith("/c/") diff --git a/app/src/main/java/dev/typetype/android/feature/player/AutoplayCountdown.kt b/app/src/main/java/dev/typetype/android/feature/player/AutoplayCountdown.kt index 57fb31da..de74540c 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/AutoplayCountdown.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/AutoplayCountdown.kt @@ -126,6 +126,31 @@ internal fun rememberAutoplayCountdown( } } + LaunchedEffect(player, currentVideoUrl, enabled, boundedSeconds, target?.videoUrl) { + while (true) { + delay(END_OF_STREAM_CHECK_MILLIS) + val p = player ?: continue + if (!enabled) continue + val next = target ?: continue + if (handledTargetUrl == next.videoUrl) continue + if (p.playbackState == Player.STATE_ENDED) continue + if (!p.playWhenReady) continue + val duration = p.duration + if (duration <= 0L) continue + val position = p.currentPosition + if (position < duration - END_OF_STREAM_EPSILON_MS) continue + handledTargetUrl = next.videoUrl + if (boundedSeconds == 0) { + latestPlayTarget(next) + } else { + remainingMillis = boundedSeconds * 1_000L + paused = false + activeTarget = next + } + break + } + } + val next = activeTarget ?: return null return AutoplayCountdownState( target = next, @@ -172,5 +197,7 @@ internal fun shouldStartAutoplayCountdown( currentMediaId == currentVideoUrl && !targetUrl.isNullOrBlank() +private const val END_OF_STREAM_CHECK_MILLIS = 2_000L +private const val END_OF_STREAM_EPSILON_MS = 1_000L private const val COUNTDOWN_TICK_MILLIS = 100L private const val MAX_AUTOPLAY_COUNTDOWN_SECONDS = 60 diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt index a039357b..48ec7a71 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerDetails.kt @@ -55,7 +55,9 @@ internal fun PlayerDetails( title = branding.title, viewCount = stream.viewCount, likeCount = stream.likeCount, + releaseDateMillis = stream.uploadedAtMillis, description = stream.description, + videoUrl = videoUrl, onTimestampClick = { player?.seekTo(it) }, ) PlaybackQueueControls(playbackQueue) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt index d9f4ef58..8bde0528 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentListItems.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -20,6 +21,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,9 +48,10 @@ internal fun CommentBody( avatarSize: Dp, onUrlClick: (String) -> Unit, onTimestampClick: (Long) -> Unit, + modifier: Modifier = Modifier, ) { val serverBaseUrl = LocalServerBaseUrl.current - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = modifier.fillMaxWidth()) { AsyncImage( model = buildImageUrl(serverBaseUrl, comment.authorAvatarUrl), contentDescription = null, @@ -82,10 +88,8 @@ internal fun CommentBody( ) } Spacer(Modifier.height(4.dp)) - LinkedText( - text = comment.text, - style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - linkColor = MaterialTheme.colorScheme.primary, + ExpandableCommentText( + comment = comment, onUrlClick = onUrlClick, onTimestampClick = onTimestampClick, ) @@ -115,6 +119,43 @@ internal fun CommentBody( } } +@Composable +private fun ExpandableCommentText( + comment: Comment, + onUrlClick: (String) -> Unit, + onTimestampClick: (Long) -> Unit, +) { + var expanded by remember(comment.text) { mutableStateOf(false) } + val renderedText = remember(comment.text) { richMarkupPlainText(comment.text) } + val needsTruncation = renderedText.length > COMMENT_COLLAPSE_CHARACTER_LIMIT || + renderedText.count { it == '\n' } >= COMMENT_COLLAPSE_LINE_LIMIT + + Column { + CommentRichText( + text = comment.text, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), + linkColor = MaterialTheme.colorScheme.primary, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + maxLines = if (expanded) Int.MAX_VALUE else COMMENT_COLLAPSE_LINE_LIMIT, + overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis, + ) + if (needsTruncation) { + TextButton( + onClick = { expanded = !expanded }, + contentPadding = PaddingValues(horizontal = 4.dp), + modifier = Modifier.padding(top = 2.dp), + ) { + Text( + stringResource( + if (expanded) R.string.comments_show_less else R.string.comments_read_more, + ) + ) + } + } + } +} + @Composable internal fun FooterState(items: LazyPagingItems) { val state = items.loadState @@ -156,3 +197,6 @@ private fun CommentSkeletons(count: Int) { } } } + +private const val COMMENT_COLLAPSE_CHARACTER_LIMIT = 320 +private const val COMMENT_COLLAPSE_LINE_LIMIT = 6 diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt new file mode 100644 index 00000000..d00b5b1a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentRichText.kt @@ -0,0 +1,171 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle + +@Composable +internal fun CommentRichText( + text: String, + style: TextStyle, + linkColor: Color, + onUrlClick: (String) -> Unit, + onTimestampClick: (Long) -> Unit, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, +) { + val latestOnUrlClick = rememberUpdatedState(onUrlClick) + val latestOnTimestampClick = rememberUpdatedState(onTimestampClick) + val nodes = remember(text) { parseRichMarkup(text) } + val linkStyles = remember(linkColor) { + TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) + } + val highlightBackground = MaterialTheme.colorScheme.surfaceVariant + val formatStyles = remember(highlightBackground) { + mapOf( + RichMarkupFormat.Strong to SpanStyle(fontWeight = FontWeight.Bold), + RichMarkupFormat.Emphasized to SpanStyle(fontStyle = FontStyle.Italic), + RichMarkupFormat.Underline to SpanStyle(textDecoration = TextDecoration.Underline), + RichMarkupFormat.Strikethrough to SpanStyle(textDecoration = TextDecoration.LineThrough), + RichMarkupFormat.Code to SpanStyle(fontFamily = FontFamily.Monospace), + RichMarkupFormat.Keyboard to SpanStyle(fontFamily = FontFamily.Monospace), + RichMarkupFormat.Highlight to SpanStyle(background = highlightBackground), + ) + } + val annotated = remember(nodes, linkStyles, formatStyles) { + buildAnnotatedString { + appendMarkup( + nodes = nodes, + style = SpanStyle(), + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = true, + onUrlClick = latestOnUrlClick, + onTimestampClick = latestOnTimestampClick, + ) + } + } + Text( + text = annotated, + style = style, + modifier = modifier, + maxLines = maxLines, + overflow = overflow, + ) +} + +private fun AnnotatedString.Builder.appendMarkup( + nodes: List, + style: SpanStyle, + linkStyles: TextLinkStyles, + formatStyles: Map, + interactive: Boolean, + onUrlClick: State<(String) -> Unit>, + onTimestampClick: State<(Long) -> Unit>, +) { + nodes.forEach { node -> + when (node) { + is RichMarkupNode.Text -> appendText( + value = node.value, + style = style, + linkStyles = linkStyles, + interactive = interactive, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + is RichMarkupNode.Break -> append('\n') + is RichMarkupNode.Link -> withLink( + LinkAnnotation.Url( + url = node.href, + styles = linkStyles, + linkInteractionListener = { onUrlClick.value(node.href) }, + ), + ) { + appendMarkup( + nodes = node.children, + style = style, + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = false, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + } + is RichMarkupNode.Format -> appendMarkup( + nodes = node.children, + style = style.merge(formatStyles.getValue(node.format)), + linkStyles = linkStyles, + formatStyles = formatStyles, + interactive = interactive, + onUrlClick = onUrlClick, + onTimestampClick = onTimestampClick, + ) + } + } +} + +private fun AnnotatedString.Builder.appendText( + value: String, + style: SpanStyle, + linkStyles: TextLinkStyles, + interactive: Boolean, + onUrlClick: State<(String) -> Unit>, + onTimestampClick: State<(Long) -> Unit>, +) { + if (!interactive) { + withStyle(style) { append(value) } + return + } + withStyle(style) { + var cursor = 0 + interactiveTextRanges(value).forEach { range -> + append(value.substring(cursor, range.start)) + when (range) { + is InteractiveTextRange.Url -> withLink( + LinkAnnotation.Url( + url = range.value, + styles = linkStyles, + linkInteractionListener = { onUrlClick.value(range.value) }, + ), + ) { + append(value.substring(range.start, range.endExclusive)) + } + is InteractiveTextRange.Timestamp -> withLink( + LinkAnnotation.Clickable( + tag = range.positionMillis.toString(), + styles = linkStyles, + linkInteractionListener = { onTimestampClick.value(range.positionMillis) }, + ), + ) { + append(value.substring(range.start, range.endExclusive)) + } + } + cursor = range.endExclusive + } + append(value.substring(cursor)) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/CommentsSheet.kt b/app/src/main/java/dev/typetype/android/feature/player/components/CommentsSheet.kt index c58f5132..d5456a09 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/CommentsSheet.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/CommentsSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import dev.typetype.android.domain.navigation.sameVideoTimestampMillis import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -77,7 +78,14 @@ fun CommentsSheet( CommentsList( items = items, repliesByCommentId = repliesByCommentId, - onUrlClick = { pendingUrl = it }, + onUrlClick = { url -> + val timestamp = sameVideoTimestampMillis(url, videoUrl) + if (timestamp != null) { + onTimestampClick(timestamp) + } else { + pendingUrl = url + } + }, onTimestampClick = onTimestampClick, onToggleReplies = { comment -> val current = repliesByCommentId[comment.id] diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt index c60ced85..d362859f 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/DescriptionSection.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.outlined.ThumbUp import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.Event import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -30,25 +31,37 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.compose.ui.unit.sp +import dev.typetype.android.domain.navigation.sameVideoTimestampMillis +import java.text.DateFormat +import java.util.Date @Composable fun DescriptionSection( title: String, viewCount: Long, likeCount: Long, + releaseDateMillis: Long, description: String, + videoUrl: String, onTimestampClick: (Long) -> Unit, modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(false) } var pendingUrl by remember { mutableStateOf(null) } val context = LocalContext.current + val releaseDate = releaseDateMillis.takeIf { it > 0L }?.let { millis -> + DateFormat.getDateInstance( + DateFormat.MEDIUM, + LocalLocale.current.platformLocale, + ).format(Date(millis)) + } Column( modifier = modifier @@ -93,6 +106,17 @@ fun DescriptionSection( ), ) } + releaseDate?.let { + Spacer(Modifier.width(16.dp)) + VideoStat( + value = it, + icon = Icons.Outlined.Event, + contentDescription = stringResource( + dev.typetype.android.R.string.player_release_date, + it, + ), + ) + } Spacer(Modifier.weight(1f)) Icon( imageVector = if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, @@ -109,7 +133,14 @@ fun DescriptionSection( lineHeight = 22.sp, ), linkColor = MaterialTheme.colorScheme.primary, - onUrlClick = { url -> pendingUrl = url }, + onUrlClick = { url -> + val timestamp = sameVideoTimestampMillis(url, videoUrl) + if (timestamp != null) { + onTimestampClick(timestamp) + } else { + pendingUrl = url + } + }, onTimestampClick = onTimestampClick, modifier = Modifier.padding(top = 10.dp), ) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/InteractiveText.kt b/app/src/main/java/dev/typetype/android/feature/player/components/InteractiveText.kt index ff205729..21f934a0 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/InteractiveText.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/InteractiveText.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withLink import java.net.URI import java.net.URLDecoder @@ -97,6 +98,8 @@ internal fun LinkedText( onUrlClick: (String) -> Unit, modifier: Modifier = Modifier, onTimestampClick: (Long) -> Unit = {}, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, ) { val latestOnUrlClick = rememberUpdatedState(onUrlClick) val latestOnTimestampClick = rememberUpdatedState(onTimestampClick) @@ -141,5 +144,11 @@ internal fun LinkedText( } append(text.substring(cursor)) } - Text(text = annotated, style = style, modifier = modifier) + Text( + text = annotated, + style = style, + modifier = modifier, + maxLines = maxLines, + overflow = overflow, + ) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt new file mode 100644 index 00000000..a3c8d4e5 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.feature.player.components + +internal fun useExpandedPlayerControls( + smallestWindowWidthDp: Int, + playerWidthDp: Float, + playerHeightDp: Float, +): Boolean = smallestWindowWidthDp >= 600 && playerWidthDp >= 600 && playerHeightDp >= 300 diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index acb53053..ba03a8c9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -22,16 +24,19 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.StreamStoryboard import dev.typetype.android.feature.player.state.ResizeMode @Composable @OptIn(ExperimentalLayoutApi::class) fun PlayerControls( player: Player, + storyboard: StreamStoryboard? = null, title: String, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, @@ -51,7 +56,9 @@ fun PlayerControls( ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT - val expandedControls = maxWidth >= 600.dp && maxHeight >= 300.dp + val expandedControls = useExpandedPlayerControls( + LocalConfiguration.current.smallestScreenWidthDp, maxWidth.value, maxHeight.value, + ) if (!timelineScrubbing) { TopScrim( compact = compactControls, @@ -83,7 +90,11 @@ fun PlayerControls( .testTag(PLAYER_TOP_CONTROLS_TAG) .then( if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.statusBars) + Modifier.windowInsetsPadding( + WindowInsets.displayCutout.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal, + ), + ).padding(top = 4.dp) } else { Modifier }, @@ -99,6 +110,7 @@ fun PlayerControls( } BottomBar( player = player, + storyboard = storyboard, sponsorBlockSegments = sponsorBlockSegments, seekPreviewPositionMs = seekPreviewPositionMs, timelineScrubbing = timelineScrubbing, @@ -161,6 +173,7 @@ private fun BottomScrim( @Composable private fun BottomBar( player: Player, + storyboard: StreamStoryboard?, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, timelineScrubbing: Boolean, @@ -187,6 +200,7 @@ private fun BottomBar( ) { PlayerTimeBar( player = player, + storyboard = storyboard, segments = sponsorBlockSegments, previewPositionMs = seekPreviewPositionMs, compact = !isFullscreen && !expanded, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt new file mode 100644 index 00000000..e1376737 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt @@ -0,0 +1,21 @@ +package dev.typetype.android.feature.player.components + +import kotlin.math.abs +import kotlin.math.sign + +internal class HoldSpeedSteps(private val stepPx: Float) { + var factor: Float = 2f + private set + private var anchorY = 0f + + fun update(dragY: Float): Float { + val distance = dragY - anchorY + val threshold = stepPx.coerceAtLeast(1f) + if (abs(distance) >= threshold) { + val steps = (abs(distance) / threshold).toInt() + factor = (factor - sign(distance) * steps * 0.25f).coerceIn(0.25f, 4f) + anchorY += sign(distance) * steps * threshold + } + return factor + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt new file mode 100644 index 00000000..436cfe06 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureConfig.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.feature.player.components + +data class PlayerGestureConfig( + val doubleTapSeekEnabled: Boolean = true, + val doubleTapSeekSeconds: Int = 10, + val swipeSeekEnabled: Boolean = false, + val swipeBrightnessVolumeEnabled: Boolean = true, + val longPressSpeedEnabled: Boolean = true, + val accessibleControlsEnabled: Boolean = false, +) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt index 9b5fbd7b..5e073802 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt @@ -6,18 +6,12 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.automirrored.filled.VolumeDown -import androidx.compose.material.icons.automirrored.filled.VolumeMute -import androidx.compose.material.icons.automirrored.filled.VolumeOff -import androidx.compose.material.icons.filled.BrightnessHigh -import androidx.compose.material.icons.filled.BrightnessLow -import androidx.compose.material.icons.filled.BrightnessMedium import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,6 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R import dev.typetype.android.feature.player.state.DragMode @@ -34,20 +29,10 @@ import dev.typetype.android.feature.player.state.PlayerGestureState import dev.typetype.android.feature.player.state.ResizeMode import kotlin.math.abs -private const val SEEK_DRAG_MS_PER_PIXEL = 80f private const val DIRECTION_LOCK_THRESHOLD_PX = 18f private const val LEVEL_DRAG_VIEW_FRACTION = 0.75f private const val LONG_PRESS_SPEED_FACTOR = 2f -data class PlayerGestureConfig( - val doubleTapSeekEnabled: Boolean = true, - val doubleTapSeekSeconds: Int = 10, - val swipeSeekEnabled: Boolean = false, - val swipeBrightnessVolumeEnabled: Boolean = true, - val longPressSpeedEnabled: Boolean = true, - val accessibleControlsEnabled: Boolean = false, -) - @Composable fun PlayerGestureLayer( player: Player, @@ -65,7 +50,16 @@ fun PlayerGestureLayer( onBrightnessGestureStart: () -> Float = { state.brightnessFraction.floatValue }, onVolumeGestureStart: () -> Float = { state.volumeFraction.floatValue }, ) { + val currentOnSingleTap by rememberUpdatedState(onSingleTap) var savedSpeed by remember { mutableFloatStateOf(1f) } + var holdSpeed by remember { mutableFloatStateOf(2f) } + fun restoreSpeed() { + if (state.longPressBoostActive.value) { + player.setPlaybackSpeed(savedSpeed) + state.longPressBoostActive.value = false + } + } + DisposableEffect(player) { onDispose { restoreSpeed() } } Box( modifier = modifier .fillMaxSize() @@ -79,89 +73,119 @@ fun PlayerGestureLayer( var lastPosition = down.position var totalDrag = Offset.Zero var mode = DragMode.None + val speedSteps = HoldSpeedSteps(24.dp.toPx()) + var speedDragY = 0f + var seekGesture: TimelineSeekGesture? = null state.dragMode.value = DragMode.None state.seekDragStartMs.longValue = player.currentPosition state.seekDragTargetMs.longValue = player.currentPosition - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - if (event.changes.count { it.pressed } > 1) { - resetDragState(state) - break - } - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) { - when (mode) { - DragMode.Seek -> player.seekTo(state.seekDragTargetMs.longValue) - DragMode.FullscreenEnter -> onEnterFullscreenGesture() - DragMode.FullscreenExit -> onExitFullscreenGesture() - else -> Unit + try { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.changes.count { it.pressed } > 1) { + resetDragState(state) + break } - resetDragState(state) - break - } - val current = change.position - val delta = current - lastPosition - lastPosition = current - totalDrag += delta - if (mode == DragMode.None) { - if (abs(totalDrag.x) < DIRECTION_LOCK_THRESHOLD_PX && - abs(totalDrag.y) < DIRECTION_LOCK_THRESHOLD_PX - ) continue - val candidate = pickDragMode( - dragAmount = totalDrag, - startX = startX, - width = size.width.toFloat(), - ) - val allowed = when (candidate) { - DragMode.Seek -> config.swipeSeekEnabled - DragMode.Brightness, - DragMode.Volume, - -> isFullscreen && config.swipeBrightnessVolumeEnabled - DragMode.FullscreenEnter -> !isFullscreen - DragMode.FullscreenExit -> isFullscreen && fullscreenExitGestureEnabled - DragMode.None -> false + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + when (mode) { + DragMode.Seek -> player.seekTo(state.seekDragTargetMs.longValue) + DragMode.FullscreenEnter -> onEnterFullscreenGesture() + DragMode.FullscreenExit -> onExitFullscreenGesture() + else -> Unit + } + resetDragState(state) + break } - if (!allowed) continue - mode = candidate - state.dragMode.value = candidate - onGestureFeedback() - when (candidate) { - DragMode.Brightness -> { - state.brightnessFraction.floatValue = onBrightnessGestureStart() - state.brightnessOverlayActive.value = true + val current = change.position + val delta = current - lastPosition + lastPosition = current + totalDrag += delta + if (state.longPressBoostActive.value) { + change.consume() + speedDragY += delta.y + val nextSpeed = speedSteps.update(speedDragY) + if (nextSpeed != holdSpeed) { + holdSpeed = nextSpeed + player.setPlaybackSpeed(nextSpeed) + onGestureFeedback() } - DragMode.Volume -> { - state.volumeFraction.floatValue = onVolumeGestureStart() - state.volumeOverlayActive.value = true + continue + } + if (mode == DragMode.None) { + if (abs(totalDrag.x) < DIRECTION_LOCK_THRESHOLD_PX && + abs(totalDrag.y) < DIRECTION_LOCK_THRESHOLD_PX + ) continue + val candidate = pickDragMode( + dragAmount = totalDrag, + startX = startX, + width = size.width.toFloat(), + ) + val allowed = when (candidate) { + DragMode.Seek -> config.swipeSeekEnabled && player.duration > 0 + DragMode.Brightness, + DragMode.Volume, + -> isFullscreen && config.swipeBrightnessVolumeEnabled + DragMode.FullscreenEnter -> !isFullscreen + DragMode.FullscreenExit -> isFullscreen && fullscreenExitGestureEnabled + DragMode.None -> false + } + if (!allowed) continue + mode = candidate + state.dragMode.value = candidate + onGestureFeedback() + when (candidate) { + DragMode.Brightness -> { + state.brightnessFraction.floatValue = onBrightnessGestureStart() + state.brightnessOverlayActive.value = true + } + DragMode.Volume -> { + state.volumeFraction.floatValue = onVolumeGestureStart() + state.volumeOverlayActive.value = true + } + DragMode.Seek -> { + state.seekDragStartMs.longValue = player.currentPosition + seekGesture = TimelineSeekGesture( + player.currentPosition, player.duration, + size.width.toFloat(), density, + ) + state.seekDragOverlayActive.value = true + } + DragMode.FullscreenEnter -> Unit + DragMode.FullscreenExit -> Unit + DragMode.None -> Unit } - DragMode.Seek -> state.seekDragOverlayActive.value = true - DragMode.FullscreenEnter -> Unit - DragMode.FullscreenExit -> Unit - DragMode.None -> Unit + } + if (mode != DragMode.None) { + change.consume() + seekGesture?.let { seek -> + state.seekDragTargetMs.longValue = seek.move(delta.x, totalDrag.y) + if (state.fineSeeking.value != seek.fineSeeking) onGestureFeedback() + state.fineSeeking.value = seek.fineSeeking + } + handleDragMode( + state = state, + mode = mode, + delta = delta, + levelDragRangePx = levelDragRangePx( + size.width.toFloat(), + size.height.toFloat(), + ), + onAdjustBrightness = onAdjustBrightness, + onAdjustVolume = onAdjustVolume, + ) } } - if (mode != DragMode.None) { - change.consume() - handleDragMode( - player = player, - state = state, - mode = mode, - delta = delta, - levelDragRangePx = levelDragRangePx( - size.width.toFloat(), - size.height.toFloat(), - ), - onAdjustBrightness = onAdjustBrightness, - onAdjustVolume = onAdjustVolume, - ) - } + } finally { + restoreSpeed() + resetDragState(state) } } } .pointerInput(player, config) { detectTapGestures( - onTap = { onSingleTap() }, + onTap = { currentOnSingleTap() }, onDoubleTap = { offset -> val action = doubleTapAction(offset.x, size.width.toFloat()) if (!action.isEnabled(config.doubleTapSeekEnabled)) { @@ -189,18 +213,10 @@ fun PlayerGestureLayer( onLongPress = { if (!config.longPressSpeedEnabled) return@detectTapGestures savedSpeed = player.playbackParameters.speed + holdSpeed = LONG_PRESS_SPEED_FACTOR player.setPlaybackSpeed(LONG_PRESS_SPEED_FACTOR) state.longPressBoostActive.value = true - }, - onPress = { - try { - awaitRelease() - } finally { - if (state.longPressBoostActive.value) { - player.setPlaybackSpeed(savedSpeed) - state.longPressBoostActive.value = false - } - } + onGestureFeedback() }, ) } @@ -234,7 +250,7 @@ fun PlayerGestureLayer( icon = volumeLevelIcon(state.volumeFraction.floatValue), modifier = Modifier.align(Alignment.Center), ) - SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = LONG_PRESS_SPEED_FACTOR) + SpeedBoostBadge(visible = state.longPressBoostActive.value, factor = holdSpeed) } } @@ -242,7 +258,6 @@ private val PlayerGestureConfig.seekIncrementMs: Long get() = doubleTapSeekSeconds.coerceIn(5, 30) * 1_000L private fun handleDragMode( - player: Player, state: PlayerGestureState, mode: DragMode, delta: Offset, @@ -269,12 +284,7 @@ private fun handleDragMode( state.volumeFraction.floatValue = next onAdjustVolume(next) } - DragMode.Seek -> { - val deltaMs = (delta.x * SEEK_DRAG_MS_PER_PIXEL).toLong() - val duration = if (player.duration > 0) player.duration else Long.MAX_VALUE - state.seekDragTargetMs.longValue = - (state.seekDragTargetMs.longValue + deltaMs).coerceIn(0L, duration) - } + DragMode.Seek, DragMode.FullscreenEnter, DragMode.FullscreenExit, DragMode.None, @@ -288,21 +298,9 @@ internal fun adjustLevelFraction(current: Float, deltaY: Float, dragRangePx: Flo internal fun levelDragRangePx(width: Float, height: Float): Float = minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION -internal fun brightnessLevelIcon(fraction: Float): ImageVector = when { - fraction < 0.25f -> Icons.Filled.BrightnessLow - fraction < 0.75f -> Icons.Filled.BrightnessMedium - else -> Icons.Filled.BrightnessHigh -} - -internal fun volumeLevelIcon(fraction: Float): ImageVector = when { - fraction <= 0f -> Icons.AutoMirrored.Filled.VolumeOff - fraction < 0.25f -> Icons.AutoMirrored.Filled.VolumeMute - fraction < 0.75f -> Icons.AutoMirrored.Filled.VolumeDown - else -> Icons.AutoMirrored.Filled.VolumeUp -} - internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { - abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.Seek + abs(dragAmount.x) > abs(dragAmount.y) * 1.5f -> DragMode.Seek + abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.None startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y > 0f -> DragMode.FullscreenExit startX < width / 2f -> DragMode.Brightness @@ -315,4 +313,5 @@ private fun resetDragState(state: PlayerGestureState) { state.brightnessOverlayActive.value = false state.volumeOverlayActive.value = false state.seekDragOverlayActive.value = false + state.fineSeeking.value = false } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt new file mode 100644 index 00000000..0f0ce166 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerLevelIcons.kt @@ -0,0 +1,24 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeMute +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.filled.BrightnessHigh +import androidx.compose.material.icons.filled.BrightnessLow +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.ui.graphics.vector.ImageVector + +internal fun brightnessLevelIcon(fraction: Float): ImageVector = when { + fraction < 0.25f -> Icons.Filled.BrightnessLow + fraction < 0.75f -> Icons.Filled.BrightnessMedium + else -> Icons.Filled.BrightnessHigh +} + +internal fun volumeLevelIcon(fraction: Float): ImageVector = when { + fraction <= 0f -> Icons.AutoMirrored.Filled.VolumeOff + fraction < 0.25f -> Icons.AutoMirrored.Filled.VolumeMute + fraction < 0.75f -> Icons.AutoMirrored.Filled.VolumeDown + else -> Icons.AutoMirrored.Filled.VolumeUp +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index 7b4387c3..5179c9f6 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -235,12 +235,14 @@ internal fun PlayerSurfaceBox( PlayerSurfaceChrome( player = player, + stream = stream, title = stream.title, sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue .takeIf { gestureState.seekDragOverlayActive.value }, seekDragOverlayVisible = gestureState.seekDragOverlayActive.value, seekDragPositionMs = gestureState.seekDragTargetMs.longValue, + fineSeeking = gestureState.fineSeeking.value, isFullscreen = isFullscreen, isInPip = isInPip, controlsAllowedByProgress = controlsAllowedByProgress, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt index f93e944c..79caa83e 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt @@ -4,25 +4,32 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R import androidx.media3.session.MediaController import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.Stream import dev.typetype.android.feature.player.state.ResizeMode @Composable internal fun PlayerSurfaceChrome( player: MediaController, + stream: Stream, title: String, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, seekDragOverlayVisible: Boolean, seekDragPositionMs: Long, + fineSeeking: Boolean, isFullscreen: Boolean, isInPip: Boolean, controlsAllowedByProgress: Boolean, @@ -49,32 +56,46 @@ internal fun PlayerSurfaceChrome( exit = fadeOut(), modifier = Modifier.align(Alignment.BottomCenter), ) { - PlayerSeekScrubOverlay( - player = player, - positionMs = seekDragPositionMs, - segments = sponsorBlockSegments, - isFullscreen = isFullscreen, - modifier = Modifier - .fillMaxWidth() - .then( - if (isFullscreen) { - Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) - } else { - Modifier.padding(start = 4.dp, end = 4.dp) - }, - ), - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SeekStoryboardPreview( + frame = stream.storyboard?.frameAt(seekDragPositionMs), + timecode = if (fineSeeking) { + stringResource( + R.string.player_fine_seek_position, + formatPlayerTime(seekDragPositionMs), + ) + } else { + formatPlayerTime(seekDragPositionMs) + }, + ) + PlayerSeekScrubOverlay( + player = player, + positionMs = seekDragPositionMs, + segments = sponsorBlockSegments, + isFullscreen = isFullscreen, + modifier = Modifier + .fillMaxWidth() + .then( + if (isFullscreen) { + Modifier.padding(start = 12.dp, end = 8.dp, bottom = 6.dp) + } else { + Modifier.padding(start = 4.dp, end = 4.dp) + }, + ), + ) + } } AnimatedVisibility( visible = controlsAllowedByProgress && (controlsVisible || accessibleControls) && - !isInPip && acceptsInput, + !isInPip && acceptsInput && !seekDragOverlayVisible, enter = fadeIn(), exit = fadeOut(), ) { PlayerControls( player = player, + storyboard = stream.storyboard, title = title, onNavigateBack = onNavigateBack, onOpenOptions = onOpenOptions, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceSnapping.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceSnapping.kt new file mode 100644 index 00000000..4b693106 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceSnapping.kt @@ -0,0 +1,5 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalPlayerSurfaceSnapping = staticCompositionLocalOf { false } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index 931ec824..b1c5cd8f 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -35,6 +36,7 @@ import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -43,6 +45,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval import dev.typetype.android.R import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.StreamStoryboard import kotlin.time.Duration.Companion.milliseconds private const val TICK_INTERVAL_MS = 200L @@ -58,11 +61,14 @@ private val COMPACT_THUMB_WIDTH = 10.dp private val COMPACT_THUMB_HEIGHT = 10.dp private val SCRUBBING_TRACK_HEIGHT = 6.dp private val SCRUBBING_THUMB_SIZE = 22.dp +private val CLASSIC_PREVIEW_WIDTH = 160.dp +private val CLASSIC_PREVIEW_LIFT = 52.dp @OptIn(markerClass = [UnstableApi::class]) @Composable fun PlayerTimeBar( player: Player, + storyboard: StreamStoryboard? = null, modifier: Modifier = Modifier, segments: List = emptyList(), compact: Boolean = false, @@ -81,63 +87,86 @@ fun PlayerTimeBar( val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + BoxWithConstraints(modifier = modifier) { + val scrubFraction = if (durationMs > 0L) { + (displayedPosMs.toFloat() / durationMs).coerceIn(0f, 1f) + } else { + 0f + } + val previewOffsetX = (maxWidth - CLASSIC_PREVIEW_WIDTH).coerceAtLeast(0.dp) * scrubFraction + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = positionLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), - color = Color.White, - modifier = if (compact) { - Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) - } else { - Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) - }, - textAlign = TextAlign.End, - ) - TimelineTrack( - positionMs = displayedPosMs, - durationMs = durationMs, - segments = segments, - compact = compact, - expanded = expanded, - emphasized = emphasized, - onScrub = { - onScrubbingChange(true) - scrubPositionMs = it - }, - onScrubFinished = { targetMs -> - player.seekTo(targetMs) - onScrubbingChange(false) - scrubPositionMs = null - }, - onScrubCancelled = { - onScrubbingChange(false) - scrubPositionMs = null - }, - accessibilityLabel = stringResource(R.string.player_timeline), - accessibilityStateDescription = stringResource( - R.string.player_timeline_position, - positionLabel, - durationLabel, - ), - modifier = Modifier - .weight(1f) - .padding(horizontal = if (compact) 2.dp else 4.dp) - .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), - ) - Text( - text = durationLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), - color = Color.White.copy(alpha = 0.7f), - modifier = if (compact) { - Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) - } else { - Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) - }, - ) + Text( + text = positionLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), + color = Color.White, + modifier = if (compact) { + Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) + } else { + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) + }, + textAlign = TextAlign.End, + ) + TimelineTrack( + positionMs = displayedPosMs, + durationMs = durationMs, + segments = segments, + compact = compact, + expanded = expanded, + emphasized = emphasized, + onScrub = { + onScrubbingChange(true) + scrubPositionMs = it + }, + onScrubFinished = { targetMs -> + player.seekTo(targetMs) + onScrubbingChange(false) + scrubPositionMs = null + }, + onScrubCancelled = { + onScrubbingChange(false) + scrubPositionMs = null + }, + accessibilityLabel = stringResource(R.string.player_timeline), + accessibilityStateDescription = stringResource( + R.string.player_timeline_position, + positionLabel, + durationLabel, + ), + modifier = Modifier + .weight(1f) + .padding(horizontal = if (compact) 2.dp else 4.dp) + .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), + ) + Text( + text = durationLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), + color = Color.White.copy(alpha = 0.7f), + modifier = if (compact) { + Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) + } else { + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) + }, + ) + } + + val classicScrubPositionMs = scrubPositionMs + if (classicScrubPositionMs != null) { + Box( + modifier = Modifier + .align(Alignment.TopStart) + .offset(x = previewOffsetX, y = -CLASSIC_PREVIEW_LIFT), + ) { + SeekStoryboardPreview( + frame = storyboard?.frameAt(classicScrubPositionMs), + timecode = formatPlayerTime(classicScrubPositionMs), + ) + } + } } } @@ -282,7 +311,7 @@ internal fun PlayerSeekScrubOverlay( modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier) { - val expanded = maxWidth >= 600.dp + val expanded = LocalConfiguration.current.smallestScreenWidthDp >= 600 && maxWidth >= 600.dp TimelineTrack( positionMs = positionMs, durationMs = player.duration.coerceAtLeast(0L), diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/ResilientPlayerSurface.kt b/app/src/main/java/dev/typetype/android/feature/player/components/ResilientPlayerSurface.kt index d2523be8..1ccba5e9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/ResilientPlayerSurface.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/ResilientPlayerSurface.kt @@ -6,11 +6,14 @@ import android.view.View import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -47,6 +50,7 @@ internal fun ResilientPlayerSurface( captionStyles: CaptionStyles = CaptionStyles(), modifier: Modifier = Modifier, ) { + val surfaceSnapping = LocalPlayerSurfaceSnapping.current val lifecycleOwner = LocalLifecycleOwner.current var videoAspectRatio by remember(player) { mutableFloatStateOf(player.videoSize.aspectRatio()) } var view by remember(surfaceKey) { mutableStateOf(null) } @@ -55,14 +59,19 @@ internal fun ResilientPlayerSurface( videoAspectRatio = videoAspectRatio, containerAspectRatio = constraints.containerAspectRatio(), ) + val scaleSpec: FiniteAnimationSpec = if (surfaceSnapping) { + snap() + } else { + tween(durationMillis = RESIZE_ANIMATION_MS, easing = FastOutSlowInEasing) + } val scaleX by animateFloatAsState( targetValue = targetScale.x, - animationSpec = tween(durationMillis = RESIZE_ANIMATION_MS, easing = FastOutSlowInEasing), + animationSpec = scaleSpec, label = "playerSurfaceScaleX", ) val scaleY by animateFloatAsState( targetValue = targetScale.y, - animationSpec = tween(durationMillis = RESIZE_ANIMATION_MS, easing = FastOutSlowInEasing), + animationSpec = scaleSpec, label = "playerSurfaceScaleY", ) key(surfaceKey) { @@ -71,7 +80,7 @@ internal fun ResilientPlayerSurface( val parent = FrameLayout(context) (LayoutInflater.from(context).inflate(R.layout.player_view_texture, parent, false) as PlayerView).apply { useController = false - setShutterBackgroundColor(Color.BLACK) + setShutterBackgroundColor(Color.TRANSPARENT) retainContentAcrossPlayerResets() this.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT subtitleView?.visibility = diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt b/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt new file mode 100644 index 00000000..4750fcc6 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/RichMarkupParser.kt @@ -0,0 +1,137 @@ +package dev.typetype.android.feature.player.components + +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import java.net.URI + +internal enum class RichMarkupFormat { + Strong, + Emphasized, + Underline, + Strikethrough, + Code, + Keyboard, + Highlight, +} + +internal sealed interface RichMarkupNode { + data class Text(val value: String) : RichMarkupNode + + data object Break : RichMarkupNode + + data class Link(val href: String, val children: List) : RichMarkupNode + + data class Format(val format: RichMarkupFormat, val children: List) : RichMarkupNode +} + +private val formatTags = setOf( + "b", "strong", "em", "i", "u", "s", "strike", "del", "code", "kbd", "mark", +) +private val blockTags = setOf( + "address", "article", "aside", "blockquote", "div", "li", "p", "pre", +) +private val omittedTags = setOf( + "audio", "base", "embed", "form", "iframe", "img", "link", "meta", + "object", "script", "style", "svg", "template", "video", +) +private val escapedMarkupPattern = Regex( + pattern = """<\s*(?:a|br|b|strong|em|i|u|s|p|div)\b""", + options = setOf(RegexOption.IGNORE_CASE), +) + +internal fun parseRichMarkup(source: String): List { + val document = Jsoup.parseBodyFragment(source).body() + val nodes = parseChildren(document.childNodes()) + val markupFree = nodes.all { it is RichMarkupNode.Text } + if (markupFree && escapedMarkupPattern.containsMatchIn(source)) { + val decoded = document.text() + if (decoded != source) { + return parseChildren(Jsoup.parseBodyFragment(decoded).body().childNodes()) + } + } + return nodes +} + +internal fun richMarkupPlainText(source: String): String { + val builder = StringBuilder() + fun visit(nodes: List) { + nodes.forEach { node -> + when (node) { + is RichMarkupNode.Text -> builder.append(node.value) + is RichMarkupNode.Break -> builder.append('\n') + is RichMarkupNode.Link -> visit(node.children) + is RichMarkupNode.Format -> visit(node.children) + } + } + } + visit(parseRichMarkup(source)) + return builder.toString() +} + +private fun parseChildren(nodes: List): List { + val result = mutableListOf() + nodes.forEach { node -> + when (node) { + is TextNode -> if (node.wholeText.isNotEmpty()) { + result += RichMarkupNode.Text(node.wholeText) + } + is Element -> parseElement(node, result) + else -> Unit + } + } + return result +} + +private fun parseElement(element: Element, result: MutableList) { + val tag = element.tagName().lowercase() + if (tag in omittedTags) return + if (tag == "br") { + appendBreak(result) + return + } + val children = parseChildren(element.childNodes()) + if (tag == "a") { + val href = element.attr("href") + if (isSafeHttpUrl(href)) { + result += RichMarkupNode.Link(href, children) + } else { + result += children + } + return + } + formatForTag(tag)?.let { format -> + result += RichMarkupNode.Format(format, children) + return + } + if (tag in blockTags) { + appendBreak(result) + result += children + appendBreak(result) + return + } + result += children +} + +private fun formatForTag(tag: String): RichMarkupFormat? = when (tag) { + "b", "strong" -> RichMarkupFormat.Strong + "em", "i" -> RichMarkupFormat.Emphasized + "u" -> RichMarkupFormat.Underline + "s", "strike", "del" -> RichMarkupFormat.Strikethrough + "code" -> RichMarkupFormat.Code + "kbd" -> RichMarkupFormat.Keyboard + "mark" -> RichMarkupFormat.Highlight + else -> null +} + +private fun appendBreak(nodes: MutableList) { + if (nodes.isNotEmpty() && nodes.last() != RichMarkupNode.Break) { + nodes += RichMarkupNode.Break + } +} + +private fun isSafeHttpUrl(value: String): Boolean = runCatching { + val scheme = URI(value).scheme ?: return@runCatching false + scheme.equals("http", ignoreCase = true) || scheme.equals("https", ignoreCase = true) +}.getOrDefault(false) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt b/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt new file mode 100644 index 00000000..f8903e82 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt @@ -0,0 +1,88 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.typetype.android.domain.stream.StreamStoryboardFrame + +internal const val PLAYER_SEEK_STORYBOARD_PREVIEW_TAG = "player_seek_storyboard_preview" + +private val PREVIEW_WIDTH = 160.dp +private val PREVIEW_HEIGHT = 90.dp + +@Composable +internal fun SeekStoryboardPreview( + frame: StreamStoryboardFrame?, + timecode: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .width(PREVIEW_WIDTH) + .testTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG), + ) { + frame?.let { StoryboardFrame(frame = it) } + Text( + text = timecode, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(top = 4.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun StoryboardFrame(frame: StreamStoryboardFrame) { + val scale = PREVIEW_WIDTH.value / frame.width + val spriteWidth = (frame.width * scale).dp + val spriteHeight = (frame.height * scale).dp + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f), + shape = RoundedCornerShape(12.dp), + ) + .padding(3.dp), + ) { + Box( + modifier = Modifier + .size(PREVIEW_WIDTH, PREVIEW_HEIGHT) + .clipToBounds(), + ) { + AsyncImage( + model = frame.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize( + width = spriteWidth, + height = spriteHeight, + ) + .offset(x = (-frame.x * scale).dp, y = (-frame.y * scale).dp), + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt b/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt new file mode 100644 index 00000000..d74fb019 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.feature.player.components + +internal class TimelineSeekGesture( + startMs: Long, + private val durationMs: Long, + private val viewportWidthPx: Float, + private val density: Float, +) { + private var position = startMs.toDouble().coerceIn(0.0, durationMs.coerceAtLeast(0).toDouble()) + var fineSeeking = false + private set + + fun move(deltaX: Float, downwardTravelPx: Float): Long { + if (durationMs <= 0 || viewportWidthPx <= 0 || density <= 0 || + !viewportWidthPx.isFinite() || !density.isFinite() || + !deltaX.isFinite() || !downwardTravelPx.isFinite() + ) return position.toLong() + + val downwardDp = downwardTravelPx / density + fineSeeking = when { + downwardDp >= 48f -> true + downwardDp <= 32f -> false + else -> fineSeeking + } + val normalMsPerPixel = durationMs.toDouble() / viewportWidthPx + val msPerPixel = if (fineSeeking) { + minOf(normalMsPerPixel / 10.0, 100.0 / density) + } else { + normalMsPerPixel + } + position = (position + deltaX * msPerPixel).coerceIn(0.0, durationMs.toDouble()) + return position.toLong() + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt index 3ef5debb..cf51c769 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/host/PlayerHostMotionLayout.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -37,6 +38,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity +import dev.typetype.android.feature.player.components.LocalPlayerSurfaceSnapping import kotlin.math.roundToInt import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -75,6 +77,11 @@ internal fun PlayerHostMotionLayout( val interactionSource = remember { MutableInteractionSource() } val isDragged by interactionSource.collectIsDraggedAsState() var dragSettlementPending by remember { mutableStateOf(false) } + val viewportSnapping by remember { + derivedStateOf { + isDragged || transitionProgress.floatValue > 0.001f + } + } val flingBehavior = AnchoredDraggableDefaults.flingBehavior(anchoredState) val nestedScrollConnection = remember(anchoredState, dragEnabled, flingBehavior) { playerHostNestedScrollConnection(anchoredState, flingBehavior, dragEnabled) @@ -158,15 +165,17 @@ internal fun PlayerHostMotionLayout( ) } ) { - expandedContent { transitionProgress.floatValue } - if (miniContentEnabled && miniContentVisible) { - Box( - modifier = Modifier.graphicsLayer { - val progress = transitionProgress.floatValue - alpha = ((progress - 0.55f) / 0.45f).coerceIn(0f, 1f) - }, - ) { - miniContent() + CompositionLocalProvider(LocalPlayerSurfaceSnapping provides viewportSnapping) { + expandedContent { transitionProgress.floatValue } + if (miniContentEnabled && miniContentVisible) { + Box( + modifier = Modifier.graphicsLayer { + val progress = transitionProgress.floatValue + alpha = ((progress - 0.55f) / 0.45f).coerceIn(0f, 1f) + }, + ) { + miniContent() + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt index a8a62c06..a340b329 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt @@ -33,6 +33,7 @@ class PlayerGestureState { val seekDragStartMs: MutableLongState = mutableLongStateOf(0L) val seekDragTargetMs: MutableLongState = mutableLongStateOf(0L) val seekDragOverlayActive: MutableState = mutableStateOf(false) + val fineSeeking: MutableState = mutableStateOf(false) val longPressBoostActive: MutableState = mutableStateOf(false) fun showSeekHint(side: GestureSide, seconds: Int) { diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchAction.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchAction.kt index df2d5476..81c33919 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchAction.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchAction.kt @@ -5,10 +5,11 @@ sealed interface SearchAction { data object OnSearch : SearchAction data class OnSuggestionClick(val query: String) : SearchAction data object OnClearQuery : SearchAction + data class OnServiceSelect(val service: Int) : SearchAction data class OnContentFilterSelect(val value: String?) : SearchAction data class OnFilterToggle(val groupKey: String, val optionValue: String) : SearchAction data object OnResetFilters : SearchAction data object OnLoadMore : SearchAction - data class OnDeleteHistoryEntry(val query: String) : SearchAction + data object OnClearHistory : SearchAction data class OnHistoryEntryClick(val query: String) : SearchAction } diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt index c097f22c..5f560242 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchResultCards.kt @@ -79,11 +79,22 @@ fun SearchChannelCard( } } Text( - text = stringResource( - R.string.search_channel_metadata, - formatCount(channel.subscriberCount), - formatCount(channel.streamCount), - ), + text = when { + channel.subscriberCount >= 0 && channel.streamCount >= 0 -> stringResource( + R.string.search_channel_metadata, + formatCount(channel.subscriberCount), + formatCount(channel.streamCount), + ) + channel.subscriberCount >= 0 -> stringResource( + R.string.search_channel_metadata_subscribers_only, + formatCount(channel.subscriberCount), + ) + channel.streamCount >= 0 -> stringResource( + R.string.search_channel_videos_count, + formatCount(channel.streamCount), + ) + else -> stringResource(R.string.search_channel_metadata_unavailable) + }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt index 70b20f51..08ce4aa5 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchScreen.kt @@ -106,17 +106,23 @@ fun SearchScreen( onNavigateBack = onNavigateBack, focusRequester = focusRequester, ) - SearchFilterBar( - contentFilters = state.contentFilters, - filterGroups = state.filterGroups, - selectedContent = state.selectedContentFilter, - selectedFilters = state.selectedFilters, - onContentSelect = { onAction(SearchAction.OnContentFilterSelect(it)) }, - onFilterToggle = { groupKey, value -> - onAction(SearchAction.OnFilterToggle(groupKey, value)) - }, - onResetFilters = { onAction(SearchAction.OnResetFilters) }, + SearchServiceSelector( + service = state.service, + onServiceSelect = { onAction(SearchAction.OnServiceSelect(it)) }, ) + if (state.hasSearched) { + SearchFilterBar( + contentFilters = state.contentFilters, + filterGroups = state.filterGroups, + selectedContent = state.selectedContentFilter, + selectedFilters = state.selectedFilters, + onContentSelect = { onAction(SearchAction.OnContentFilterSelect(it)) }, + onFilterToggle = { groupKey, value -> + onAction(SearchAction.OnFilterToggle(groupKey, value)) + }, + onResetFilters = { onAction(SearchAction.OnResetFilters) }, + ) + } when { state.isLoading -> Box( @@ -141,7 +147,7 @@ fun SearchScreen( onSuggestionClick = ::submitTerm, onSuggestionFill = { onAction(SearchAction.OnQueryChange(it)) }, onHistoryClick = ::submitTerm, - onDeleteHistory = { onAction(SearchAction.OnDeleteHistoryEntry(it)) }, + onClearHistory = { onAction(SearchAction.OnClearHistory) }, ) else -> SearchResultsGrid( state = state, diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchServiceSelector.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchServiceSelector.kt new file mode 100644 index 00000000..4f892ba0 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchServiceSelector.kt @@ -0,0 +1,80 @@ +package dev.typetype.android.feature.search + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.typetype.android.R + +@Immutable +private data class SearchService( + val id: Int, + val labelRes: Int, + val iconRes: Int, + val brandColor: Color, +) + +private val SEARCH_SERVICES = listOf( + SearchService( + id = 0, + labelRes = R.string.settings_default_service_youtube, + iconRes = R.drawable.ic_service_youtube, + brandColor = Color(0xFFFF0000), + ), + SearchService( + id = 6, + labelRes = R.string.settings_default_service_niconico, + iconRes = R.drawable.ic_service_niconico, + brandColor = Color(0xFF231815), + ), + SearchService( + id = 5, + labelRes = R.string.settings_default_service_bilibili, + iconRes = R.drawable.ic_service_bilibili, + brandColor = Color(0xFF00A1D6), + ), +) + +@Composable +internal fun SearchServiceSelector( + service: Int, + onServiceSelect: (Int) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SEARCH_SERVICES.forEach { candidate -> + val label = stringResource(candidate.labelRes) + FilterChip( + selected = service == candidate.id, + onClick = { onServiceSelect(candidate.id) }, + leadingIcon = { + Icon( + painter = painterResource(candidate.iconRes), + contentDescription = null, + tint = candidate.brandColor, + modifier = Modifier.size(18.dp), + ) + }, + label = { Text(label) }, + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/search/SearchState.kt b/app/src/main/java/dev/typetype/android/feature/search/SearchState.kt index 450783c8..198ceca8 100644 --- a/app/src/main/java/dev/typetype/android/feature/search/SearchState.kt +++ b/app/src/main/java/dev/typetype/android/feature/search/SearchState.kt @@ -8,6 +8,7 @@ import dev.typetype.android.domain.search.SearchPlaylist data class SearchState( val query: String = "", + val service: Int = 0, val isLoading: Boolean = false, val results: List